Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5371b5d236 | |||
| 83b1fa23d0 | |||
| f8e7b2e42d | |||
| a5b65061f0 | |||
| 0fb971dd0a | |||
| 75fb1a6ab9 | |||
| 652fcccb4a | |||
| bfb27427ce | |||
| d9c48370df | |||
| 4c8c0461be | |||
| a02f71f304 | |||
| 83c10b2b00 | |||
| 8c0dd0bb6b | |||
| 7a374f1162 | |||
| a5afcf168b | |||
| 2f83fe03d2 | |||
| 3c10d78e78 | |||
| 8c0df65c0c | |||
| 47bb1cb98d | |||
| 20547e60e4 | |||
| 0fd3106cb8 | |||
| 108a141c65 | |||
| 6a1b7b0160 | |||
| 3c8d99196a | |||
| cae86953a0 | |||
| f661747a0c | |||
| 85feb67299 | |||
| 4843d7d6c2 | |||
| a0b9e49486 | |||
| 0163e85e76 | |||
| 7fb6fa3af4 | |||
| bb8788f03d | |||
| ed4434512b | |||
| f6344d43d2 | |||
| e0745bdfbe | |||
| a0a02cb4be | |||
| 7866f7258a | |||
| 5cd82f9dd5 | |||
| c85e9cb5f1 | |||
| 4837dd44c9 | |||
| e3f42c28d0 | |||
| 4a11d47692 | |||
| 612c760755 | |||
| 14b8ecbe44 | |||
| a6c61ccd0d | |||
| 80209240d2 | |||
| 1d5b62ccf9 | |||
| af8235c6b3 | |||
| 8e9f02c807 | |||
| fd20ac5d84 | |||
| 23492961d5 | |||
| 2326775d78 | |||
| e0a84a521b | |||
| d217ad5744 | |||
| d5ae41cdab | |||
| 7a01db05dd | |||
| d392fc3818 | |||
| 47596d5f72 | |||
| dbc25e5730 |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Pinchflat Dev",
|
||||||
|
"dockerComposeFile": "../docker-compose.yml",
|
||||||
|
"service": "phx",
|
||||||
|
"workspaceFolder": "/app",
|
||||||
|
"shutdownAction": "stopCompose",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["phoenixframework.phoenix", "JakeBecker.elixir-ls", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/selfhosted.Dockerfile
|
file: ./docker/selfhosted.Dockerfile
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build docker image
|
- name: Build docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/dev.Dockerfile
|
file: ./docker/dev.Dockerfile
|
||||||
|
|||||||
@@ -23,3 +23,11 @@ alias Pinchflat.Metadata.MetadataFileHelpers
|
|||||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||||
|
|
||||||
Pinchflat.Release.check_file_permissions()
|
Pinchflat.Release.check_file_permissions()
|
||||||
|
|
||||||
|
defmodule IexHelpers do
|
||||||
|
def restart do
|
||||||
|
:init.restart()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
import IexHelpers
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
assets/vendor/
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/kieraneglin/pinchflat/releases)
|
[](https://github.com/kieraneglin/pinchflat/releases)
|
||||||
[](#)
|
[](#)
|
||||||
|
[](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/kieraneglin/pinchflat)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||||||
- Self-contained - just one Docker container with no external dependencies
|
- Self-contained - just one Docker container with no external dependencies
|
||||||
- Powerful naming system so content is stored where and how you want it
|
- Powerful naming system so content is stored where and how you want it
|
||||||
- Easy-to-use web interface with presets to get you started right away
|
- Easy-to-use web interface with presets to get you started right away
|
||||||
- First-class support for media center apps like Plex, Jellyfin, and Kodi
|
- First-class support for media center apps like Plex, Jellyfin, and Kodi ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#how-do-i-get-media-into-plexjellyfinkodi))
|
||||||
- Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds))
|
- Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds))
|
||||||
- Automatically downloads new content from channels and playlists
|
- Automatically downloads new content from channels and playlists
|
||||||
- Uses a novel approach to download new content more quickly than other apps
|
- Uses a novel approach to download new content more quickly than other apps
|
||||||
@@ -62,7 +63,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||||||
- Allows automatically redownloading new media after a set period
|
- Allows automatically redownloading new media after a set period
|
||||||
- This can help improve the download quality of new content or improve SponsorBlock tags
|
- This can help improve the download quality of new content or improve SponsorBlock tags
|
||||||
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
|
||||||
- Advanced options like setting cutoff dates and filtering by title
|
- Advanced options like setting cutoff dates and filtering by title ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#i-only-want-certain-videos-from-a-source---how-can-i-only-download-those))
|
||||||
- Reliable hands-off operation
|
- Reliable hands-off operation
|
||||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||||
- Sponsorblock integration
|
- Sponsorblock integration
|
||||||
@@ -130,9 +131,6 @@ You _must_ ensure the host directories you've mounted are writable by the user r
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media.
|
> It's not recommended to run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media.
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user.
|
|
||||||
|
|
||||||
### ADVANCED: Storing Pinchflat config directory on a network share
|
### ADVANCED: Storing Pinchflat config directory on a network share
|
||||||
|
|
||||||
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
|
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
|
||||||
@@ -144,16 +142,18 @@ If you change this setting and it works well for you, please leave a comment on
|
|||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
| Name | Required? | Default | Notes |
|
| Name | Required? | Default | Notes |
|
||||||
| --------------------- | --------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
|
| --------------------------- | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| TZ | No | `UTC` | Must follow IANA TZ format |
|
| `TZ` | No | `UTC` | Must follow IANA TZ format |
|
||||||
| LOG_LEVEL | No | `debug` | Can be set to `info` |
|
| `LOG_LEVEL` | No | `debug` | Can be set to `info` but `debug` is strongly recommended |
|
||||||
| BASIC_AUTH_USERNAME | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
| `BASIC_AUTH_USERNAME` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||||
| BASIC_AUTH_PASSWORD | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
| `BASIC_AUTH_PASSWORD` | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||||
| EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
|
| `EXPOSE_FEED_ENDPOINTS` | No | `false` | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
|
||||||
| JOURNAL_MODE | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
|
| `ENABLE_IPV6` | No | `false` | Setting to _any_ non-blank value will enable IPv6 |
|
||||||
| TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
|
| `JOURNAL_MODE` | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
|
||||||
| BASE_ROUTE_PATH | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
|
| `TZ_DATA_DIR` | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
|
||||||
|
| `BASE_ROUTE_PATH` | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies, but prefix must be stripped. |
|
||||||
|
| `YT_DLP_WORKER_CONCURRENCY` | No | `2` | The number of concurrent workers that use `yt-dlp` _per queue_. Set to 1 if you're getting IP limited, otherwise don't touch it |
|
||||||
|
|
||||||
## EFF donations
|
## EFF donations
|
||||||
|
|
||||||
@@ -161,9 +161,9 @@ Prior to 2024-05-10, a portion of all donations were given to the [Electronic Fr
|
|||||||
|
|
||||||
The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down.
|
The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down.
|
||||||
|
|
||||||
## Pre-release disclaimer
|
## Stability disclaimer
|
||||||
|
|
||||||
This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now.
|
This software is in active development and anything can break at any time. I make no guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,10 @@ window.markVersionAsSeen = (versionString) => {
|
|||||||
window.isVersionSeen = (versionString) => {
|
window.isVersionSeen = (versionString) => {
|
||||||
return localStorage.getItem('seenVersion') === versionString
|
return localStorage.getItem('seenVersion') === versionString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.dispatchFor = (elementOrId, eventName, detail = {}) => {
|
||||||
|
const element =
|
||||||
|
typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId
|
||||||
|
|
||||||
|
element.dispatchEvent(new CustomEvent(eventName, { detail }))
|
||||||
|
}
|
||||||
|
|||||||
+24
-1
@@ -39,7 +39,7 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
supressEnterSubmission: {
|
'supress-enter-submission': {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.el.addEventListener('keypress', (event) => {
|
this.el.addEventListener('keypress', (event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
@@ -47,6 +47,29 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'formless-input': {
|
||||||
|
mounted() {
|
||||||
|
const subscribedEvents = this.el.dataset.subscribe.split(' ')
|
||||||
|
const eventName = this.el.dataset.eventName || ''
|
||||||
|
const identifier = this.el.dataset.identifier || ''
|
||||||
|
|
||||||
|
subscribedEvents.forEach((domEvent) => {
|
||||||
|
this.el.addEventListener(domEvent, () => {
|
||||||
|
// This ensures that the event is pushed to the server after the input value has been updated
|
||||||
|
// so that the server has the most up-to-date value
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pushEvent('formless-input', {
|
||||||
|
value: this.el.value,
|
||||||
|
id: identifier,
|
||||||
|
event: eventName,
|
||||||
|
dom_id: this.el.id,
|
||||||
|
dom_event: domEvent
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-12
@@ -41,7 +41,8 @@ config :pinchflat, PinchflatWeb.Endpoint,
|
|||||||
adapter: Phoenix.Endpoint.Cowboy2Adapter,
|
adapter: Phoenix.Endpoint.Cowboy2Adapter,
|
||||||
render_errors: [
|
render_errors: [
|
||||||
formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON],
|
formats: [html: PinchflatWeb.ErrorHTML, json: PinchflatWeb.ErrorJSON],
|
||||||
layout: false
|
root_layout: {PinchflatWeb.Layouts, :root},
|
||||||
|
layout: {PinchflatWeb.Layouts, :app}
|
||||||
],
|
],
|
||||||
pubsub_server: Pinchflat.PubSub,
|
pubsub_server: Pinchflat.PubSub,
|
||||||
live_view: [signing_salt: "/t5878kO"]
|
live_view: [signing_salt: "/t5878kO"]
|
||||||
@@ -57,16 +58,6 @@ config :pinchflat, Oban,
|
|||||||
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
||||||
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
|
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
|
||||||
]}
|
]}
|
||||||
],
|
|
||||||
# TODO: consider making this an env var or something?
|
|
||||||
queues: [
|
|
||||||
default: 10,
|
|
||||||
fast_indexing: 6,
|
|
||||||
media_indexing: 2,
|
|
||||||
media_collection_indexing: 2,
|
|
||||||
media_fetching: 2,
|
|
||||||
local_metadata: 8,
|
|
||||||
remote_metadata: 4
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Configures the mailer
|
# Configures the mailer
|
||||||
@@ -101,7 +92,7 @@ config :tailwind,
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Configures Elixir's Logger
|
# Configures Elixir's Logger
|
||||||
config :logger, :console,
|
config :logger, :default_formatter,
|
||||||
format: "$date $time $metadata[$level] | $message\n",
|
format: "$date $time $metadata[$level] | $message\n",
|
||||||
metadata: [:request_id]
|
metadata: [:request_id]
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ config :pinchflat, PinchflatWeb.Endpoint,
|
|||||||
config :pinchflat, dev_routes: true
|
config :pinchflat, dev_routes: true
|
||||||
|
|
||||||
# Do not include metadata nor timestamps in development logs
|
# Do not include metadata nor timestamps in development logs
|
||||||
config :logger, :console, format: "[$level] $message\n"
|
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||||
|
|
||||||
# Set a higher stacktrace during development. Avoid configuring such
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
# in production as building large stacktraces may be expensive.
|
# in production as building large stacktraces may be expensive.
|
||||||
|
|||||||
+27
-6
@@ -40,35 +40,56 @@ config :pinchflat, Pinchflat.Repo,
|
|||||||
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
|
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Some users may want to increase the number of workers that use yt-dlp to improve speeds
|
||||||
|
# Others may want to decrease the number of these workers to lessen the chance of an IP ban
|
||||||
|
{yt_dlp_worker_count, _} = Integer.parse(System.get_env("YT_DLP_WORKER_CONCURRENCY", "2"))
|
||||||
|
|
||||||
|
config :pinchflat, Oban,
|
||||||
|
queues: [
|
||||||
|
default: 10,
|
||||||
|
fast_indexing: 6,
|
||||||
|
media_collection_indexing: yt_dlp_worker_count,
|
||||||
|
media_fetching: yt_dlp_worker_count,
|
||||||
|
remote_metadata: yt_dlp_worker_count,
|
||||||
|
local_data: 8
|
||||||
|
]
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
config_path = "/config"
|
# Various paths. These ones shouldn't be tweaked if running in Docker
|
||||||
|
media_path = System.get_env("MEDIA_PATH", "/downloads")
|
||||||
|
config_path = System.get_env("CONFIG_PATH", "/config")
|
||||||
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
|
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
|
||||||
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
|
||||||
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
||||||
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
||||||
|
tmpfile_path = System.get_env("TMPFILE_PATH", Path.join([System.tmp_dir!(), "pinchflat", "data"]))
|
||||||
|
# This one can be changed if you want
|
||||||
|
tz_data_path = System.get_env("TZ_DATA_PATH", Path.join([extras_path, "elixir_tz_data"]))
|
||||||
# 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
|
# For running PF in a subdirectory via a reverse proxy
|
||||||
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
|
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
|
||||||
|
enable_ipv6 = String.length(System.get_env("ENABLE_IPV6", "")) > 0
|
||||||
|
|
||||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
|
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
|
||||||
|
|
||||||
config :pinchflat,
|
config :pinchflat,
|
||||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||||
apprise_executable: System.find_executable("apprise"),
|
apprise_executable: System.find_executable("apprise"),
|
||||||
media_directory: "/downloads",
|
media_directory: media_path,
|
||||||
metadata_directory: metadata_path,
|
metadata_directory: metadata_path,
|
||||||
extras_directory: extras_path,
|
extras_directory: extras_path,
|
||||||
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
tmpfile_directory: tmpfile_path,
|
||||||
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",
|
# This is configured in application.ex
|
||||||
|
timezone: "UTC",
|
||||||
log_path: log_path,
|
log_path: log_path,
|
||||||
base_route_path: base_route_path
|
base_route_path: base_route_path
|
||||||
|
|
||||||
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
|
config :tzdata, :data_dir, tz_data_path
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: db_path,
|
database: db_path,
|
||||||
@@ -106,7 +127,7 @@ if config_env() == :prod do
|
|||||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||||
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
|
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
|
||||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||||
ip: {0, 0, 0, 0},
|
ip: if(enable_ipv6, do: {0, 0, 0, 0, 0, 0, 0, 0}, else: {0, 0, 0, 0}),
|
||||||
port: String.to_integer(System.get_env("PORT") || "4000")
|
port: String.to_integer(System.get_env("PORT") || "4000")
|
||||||
],
|
],
|
||||||
url: [path: base_route_path],
|
url: [path: base_route_path],
|
||||||
|
|||||||
@@ -10,5 +10,3 @@ services:
|
|||||||
command: bash -c "chmod +x docker/docker-run.dev.sh && docker/docker-run.dev.sh"
|
command: bash -c "chmod +x docker/docker-run.dev.sh && docker/docker-run.dev.sh"
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
|
|||||||
+10
-5
@@ -12,7 +12,7 @@ RUN echo "Building for ${TARGETPLATFORM:?}"
|
|||||||
RUN apt-get update -qq && \
|
RUN apt-get update -qq && \
|
||||||
apt-get install -y inotify-tools curl git openssh-client jq \
|
apt-get install -y inotify-tools curl git openssh-client jq \
|
||||||
python3 python3-setuptools python3-wheel python3-dev pipx \
|
python3 python3-setuptools python3-wheel python3-dev pipx \
|
||||||
python3-mutagen locales procps build-essential graphviz
|
python3-mutagen locales procps build-essential graphviz zsh
|
||||||
|
|
||||||
# Install ffmpeg
|
# Install ffmpeg
|
||||||
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
@@ -38,7 +38,10 @@ RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh && \
|
|||||||
# Install Apprise
|
# Install Apprise
|
||||||
export PIPX_HOME=/opt/pipx && \
|
export PIPX_HOME=/opt/pipx && \
|
||||||
export PIPX_BIN_DIR=/usr/local/bin && \
|
export PIPX_BIN_DIR=/usr/local/bin && \
|
||||||
pipx install apprise
|
pipx install apprise && \
|
||||||
|
# Set up ZSH tools
|
||||||
|
chsh -s $(which zsh) && \
|
||||||
|
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
|
||||||
|
|
||||||
# Set the locale
|
# Set the locale
|
||||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
@@ -46,14 +49,16 @@ ENV LANG=en_US.UTF-8
|
|||||||
ENV LANGUAGE=en_US:en
|
ENV LANGUAGE=en_US:en
|
||||||
ENV LC_ALL=en_US.UTF-8
|
ENV LC_ALL=en_US.UTF-8
|
||||||
|
|
||||||
# Create app directory and copy the Elixir projects into it.
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . ./
|
|
||||||
|
|
||||||
|
COPY mix.exs mix.lock ./
|
||||||
# Install Elixir deps
|
# Install Elixir deps
|
||||||
# RUN mix archive.install github hexpm/hex branch latest
|
# NOTE: this has to be before the bulk copy to ensure that deps are cached
|
||||||
RUN MIX_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile
|
RUN MIX_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile
|
||||||
RUN MIX_ENV=test mix deps.get && MIX_ENV=test mix deps.compile
|
RUN MIX_ENV=test mix deps.get && MIX_ENV=test mix deps.compile
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
# Gives us iex shell history
|
# Gives us iex shell history
|
||||||
ENV ERL_AFLAGS="-kernel shell_history enabled"
|
ENV ERL_AFLAGS="-kernel shell_history enabled"
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ RUN apt-get update -y && \
|
|||||||
# Hex and Rebar
|
# Hex and Rebar
|
||||||
mix local.hex --force && \
|
mix local.hex --force && \
|
||||||
mix local.rebar --force && \
|
mix local.rebar --force && \
|
||||||
# FFmpeg
|
# FFmpeg (latest build that doesn't cause an illegal instruction error for some users - see #347)
|
||||||
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||||
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;; \
|
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linux64-gpl.tar.xz" ;; \
|
||||||
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz" ;; \
|
"linux/arm64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/autobuild-2024-07-30-14-10/ffmpeg-N-116468-g0e09f6d690-linuxarm64-gpl.tar.xz" ;; \
|
||||||
*) echo "" ;; esac) && \
|
*) echo "" ;; esac) && \
|
||||||
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
|
curl -L ${FFMPEG_DOWNLOAD} --output /tmp/ffmpeg.tar.xz && \
|
||||||
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
|
tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/local/bin/ "ffmpeg" && \
|
||||||
@@ -88,6 +88,7 @@ RUN apt-get update -y && \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
python3-mutagen \
|
python3-mutagen \
|
||||||
curl \
|
curl \
|
||||||
|
zip \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
nano \
|
nano \
|
||||||
python3 \
|
python3 \
|
||||||
@@ -116,7 +117,8 @@ ENV LC_ALL en_US.UTF-8
|
|||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
|
|
||||||
# Set up data volumes
|
# Set up data volumes
|
||||||
RUN mkdir /config /downloads /etc/elixir_tzdata_data && chmod ugo+rw /etc/elixir_tzdata_data
|
RUN mkdir -p /config /downloads /etc/elixir_tzdata_data /etc/yt-dlp/plugins && \
|
||||||
|
chmod ugo+rw /etc/elixir_tzdata_data /etc/yt-dlp /etc/yt-dlp/plugins
|
||||||
|
|
||||||
# set runner ENV
|
# set runner ENV
|
||||||
ENV MIX_ENV="prod"
|
ENV MIX_ENV="prod"
|
||||||
@@ -127,17 +129,6 @@ EXPOSE ${PORT}
|
|||||||
# Only copy the final release from the build stage
|
# Only copy the final release from the build stage
|
||||||
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
|
||||||
|
|
||||||
# NEVER do this if you're running in an environment where you don't trust the user
|
|
||||||
# (ie: most environments). This is only acceptable in a self-hosted environment.
|
|
||||||
# The user could just run the whole container as root and bypass this anyway so
|
|
||||||
# it's not a huge deal.
|
|
||||||
# This removes the root password to allow users to assume root if needed. This is
|
|
||||||
# preferrable to running the whole container as root so that the files/directories
|
|
||||||
# created by the app aren't owned by root and are therefore easier for other users
|
|
||||||
# and processes to interact with. If you want to just run the whole container as
|
|
||||||
# root, use --user 0:0 or something.
|
|
||||||
RUN passwd -d root
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=120s --start-period=10s \
|
HEALTHCHECK --interval=120s --start-period=10s \
|
||||||
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ defmodule Pinchflat.Application do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
|
check_and_update_timezone()
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
PinchflatWeb.Telemetry,
|
PinchflatWeb.Telemetry,
|
||||||
Pinchflat.Repo,
|
Pinchflat.Repo,
|
||||||
@@ -47,4 +50,20 @@ defmodule Pinchflat.Application do
|
|||||||
:ok = Oban.Telemetry.attach_default_logger()
|
:ok = Oban.Telemetry.attach_default_logger()
|
||||||
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
|
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# This has to be here (rather than runtime.exs) since the `tzdata` application
|
||||||
|
# has to be started before we can check the timezone
|
||||||
|
defp check_and_update_timezone do
|
||||||
|
attempted_timezone = System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
|
||||||
|
|
||||||
|
valid_timezone =
|
||||||
|
if Tzdata.zone_exists?(attempted_timezone) do
|
||||||
|
attempted_timezone
|
||||||
|
else
|
||||||
|
Logger.warning("Invalid timezone #{attempted_timezone}, defaulting to UTC")
|
||||||
|
"UTC"
|
||||||
|
end
|
||||||
|
|
||||||
|
Application.put_env(:pinchflat, :timezone, valid_timezone)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
|
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
GenServer.start_link(__MODULE__, %{}, opts)
|
GenServer.start_link(__MODULE__, %{}, opts)
|
||||||
end
|
end
|
||||||
@@ -36,6 +38,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
create_blank_yt_dlp_files()
|
create_blank_yt_dlp_files()
|
||||||
create_blank_user_script_file()
|
create_blank_user_script_file()
|
||||||
apply_default_settings()
|
apply_default_settings()
|
||||||
|
run_app_init_script()
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
@@ -95,6 +98,12 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||||||
Settings.set(apprise_version: apprise_version)
|
Settings.set(apprise_version: apprise_version)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp run_app_init_script do
|
||||||
|
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||||
|
|
||||||
|
runner.run(:app_init, %{})
|
||||||
|
end
|
||||||
|
|
||||||
defp yt_dlp_runner do
|
defp yt_dlp_runner do
|
||||||
Application.get_env(:pinchflat, :yt_dlp_runner)
|
Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Settings
|
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Downloading.OutputPathBuilder
|
alias Pinchflat.Downloading.OutputPathBuilder
|
||||||
|
alias Pinchflat.Downloading.QualityOptionBuilder
|
||||||
|
|
||||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||||
|
|
||||||
@@ -34,21 +34,38 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Builds the output path for yt-dlp to download media based on the given source's
|
Builds the output path for yt-dlp to download media based on the given source's
|
||||||
media profile. Uses the source's override output path template if it exists.
|
or media_item's media profile. Uses the source's override output path template if it exists.
|
||||||
|
|
||||||
Accepts a %MediaItem{} or %Source{} struct. If a %Source{} struct is passed, it
|
Accepts a %MediaItem{} or %Source{} struct. If a %Source{} struct is passed, it
|
||||||
will use a default %MediaItem{} struct with the given source.
|
will use a default %MediaItem{} struct with the given source.
|
||||||
|
|
||||||
Returns binary()
|
Returns binary()
|
||||||
"""
|
"""
|
||||||
|
def build_output_path_for(%Source{} = source_with_preloads) do
|
||||||
|
build_output_path_for(%MediaItem{source: source_with_preloads})
|
||||||
|
end
|
||||||
|
|
||||||
def build_output_path_for(%MediaItem{} = media_item_with_preloads) do
|
def build_output_path_for(%MediaItem{} = media_item_with_preloads) do
|
||||||
output_path_template = Sources.output_path_template(media_item_with_preloads.source)
|
output_path_template = Sources.output_path_template(media_item_with_preloads.source)
|
||||||
|
|
||||||
build_output_path(output_path_template, media_item_with_preloads)
|
build_output_path(output_path_template, media_item_with_preloads)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_output_path_for(%Source{} = source_with_preloads) do
|
@doc """
|
||||||
build_output_path_for(%MediaItem{source: source_with_preloads})
|
Builds the quality options for yt-dlp to download media based on the given source's
|
||||||
|
or media_item's media profile. Useful for helping predict final filepath of downloaded
|
||||||
|
media.
|
||||||
|
|
||||||
|
returns [Keyword.t()]
|
||||||
|
"""
|
||||||
|
def build_quality_options_for(%Source{} = source_with_preloads) do
|
||||||
|
build_quality_options_for(%MediaItem{source: source_with_preloads})
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_quality_options_for(%MediaItem{} = media_item_with_preloads) do
|
||||||
|
media_profile = media_item_with_preloads.source.media_profile
|
||||||
|
|
||||||
|
quality_options(media_profile)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp default_options(override_opts) do
|
defp default_options(override_opts) do
|
||||||
@@ -125,26 +142,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp quality_options(media_profile) do
|
defp quality_options(media_profile) do
|
||||||
vcodec = Settings.get!(:video_codec_preference)
|
QualityOptionBuilder.build(media_profile)
|
||||||
acodec = Settings.get!(:audio_codec_preference)
|
|
||||||
|
|
||||||
case media_profile.preferred_resolution do
|
|
||||||
# Also be aware that :audio disabled all embedding options for subtitles
|
|
||||||
:audio ->
|
|
||||||
[:extract_audio, format_sort: "+acodec:#{acodec}"]
|
|
||||||
|
|
||||||
resolution_atom ->
|
|
||||||
{resolution_string, _} =
|
|
||||||
resolution_atom
|
|
||||||
|> Atom.to_string()
|
|
||||||
|> Integer.parse()
|
|
||||||
|
|
||||||
[
|
|
||||||
# Since Plex doesn't support reading metadata from MKV
|
|
||||||
remux_video: "mp4",
|
|
||||||
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
|
|
||||||
]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sponsorblock_options(media_profile) do
|
defp sponsorblock_options(media_profile) do
|
||||||
|
|||||||
@@ -91,8 +91,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
|||||||
[m, s, mp],
|
[m, s, mp],
|
||||||
^MediaQuery.for_source(source) and
|
^MediaQuery.for_source(source) and
|
||||||
^MediaQuery.downloaded() and
|
^MediaQuery.downloaded() and
|
||||||
not (^MediaQuery.download_prevented()) and
|
not (^MediaQuery.download_prevented())
|
||||||
not (^MediaQuery.culled())
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Media.FileSyncing
|
||||||
alias Pinchflat.Downloading.MediaDownloader
|
alias Pinchflat.Downloading.MediaDownloader
|
||||||
|
|
||||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||||
@@ -39,17 +40,14 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
|
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
|
||||||
if the source is set to not download media
|
if the source is set to not download media
|
||||||
|
|
||||||
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
|
Returns :ok | {:error, any, ...any}
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||||
should_force = Map.get(args, "force", false)
|
should_force = Map.get(args, "force", false)
|
||||||
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
|
is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
|
||||||
|
|
||||||
media_item =
|
media_item = fetch_and_run_prevent_download_user_script(media_item_id)
|
||||||
media_item_id
|
|
||||||
|> Media.get_media_item!()
|
|
||||||
|> Repo.preload(:source)
|
|
||||||
|
|
||||||
# If the source or media item is set to not download media, perform a no-op unless forced
|
# If the source or media item is set to not download media, perform a no-op unless forced
|
||||||
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
||||||
@@ -62,6 +60,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# If a user script exists and, when run, returns a non-zero exit code, prevent this and all future downloads
|
||||||
|
# of the media item.
|
||||||
|
defp fetch_and_run_prevent_download_user_script(media_item_id) do
|
||||||
|
media_item = Media.get_media_item!(media_item_id)
|
||||||
|
|
||||||
|
{:ok, media_item} =
|
||||||
|
case run_user_script(:media_pre_download, media_item) do
|
||||||
|
{:ok, _, exit_code} when exit_code != 0 -> Media.update_media_item(media_item, %{prevent_download: true})
|
||||||
|
_ -> {:ok, media_item}
|
||||||
|
end
|
||||||
|
|
||||||
|
Repo.preload(media_item, :source)
|
||||||
|
end
|
||||||
|
|
||||||
defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
|
defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
|
||||||
overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites
|
overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites
|
||||||
override_opts = [overwrite_behaviour: overwrite_behaviour]
|
override_opts = [overwrite_behaviour: overwrite_behaviour]
|
||||||
@@ -74,13 +86,17 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
|
||||||
})
|
})
|
||||||
|
|
||||||
:ok = run_user_script(updated_media_item)
|
:ok = FileSyncing.delete_outdated_files(media_item, updated_media_item)
|
||||||
|
run_user_script(:media_downloaded, updated_media_item)
|
||||||
|
|
||||||
{:ok, updated_media_item}
|
:ok
|
||||||
|
|
||||||
{:recovered, _} ->
|
{:recovered, _} ->
|
||||||
{:error, :retry}
|
{:error, :retry}
|
||||||
|
|
||||||
|
{:error, :unsuitable_for_download} ->
|
||||||
|
{:ok, :non_retry}
|
||||||
|
|
||||||
{:error, message} ->
|
{:error, message} ->
|
||||||
action_on_error(message)
|
action_on_error(message)
|
||||||
end
|
end
|
||||||
@@ -112,9 +128,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||||||
|
|
||||||
# NOTE: I like this pattern of using the default value so that I don't have to
|
# NOTE: I like this pattern of using the default value so that I don't have to
|
||||||
# define it in config.exs (and friends). Consider using this elsewhere.
|
# define it in config.exs (and friends). Consider using this elsewhere.
|
||||||
defp run_user_script(media_item) do
|
defp run_user_script(event, media_item) do
|
||||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||||
|
|
||||||
runner.run(:media_downloaded, media_item)
|
runner.run(event, media_item)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ defmodule Pinchflat.Downloading.MediaDownloader 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)
|
||||||
|
|
||||||
|
{:error, :unsuitable_for_download} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Media item ##{media_with_preloads.id} isn't suitable for download yet. May be an active or processing live stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :unsuitable_for_download}
|
||||||
|
|
||||||
{:error, message, _exit_code} ->
|
{:error, message, _exit_code} ->
|
||||||
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
|
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
|
||||||
|
|
||||||
@@ -79,6 +86,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
|> MetadataParser.parse_for_media_item()
|
|> MetadataParser.parse_for_media_item()
|
||||||
|> Map.merge(%{
|
|> Map.merge(%{
|
||||||
media_downloaded_at: DateTime.utc_now(),
|
media_downloaded_at: DateTime.utc_now(),
|
||||||
|
culled_at: nil,
|
||||||
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
|
nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json),
|
||||||
metadata: %{
|
metadata: %{
|
||||||
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
||||||
@@ -105,8 +113,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
|
|
||||||
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
|
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
|
||||||
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
|
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
|
||||||
|
runner_opts = [output_filepath: output_filepath, use_cookies: item_with_preloads.source.use_cookies]
|
||||||
|
|
||||||
YtDlpMedia.download(url, options, output_filepath: output_filepath)
|
case YtDlpMedia.get_downloadable_status(url) do
|
||||||
|
{:ok, :downloadable} -> YtDlpMedia.download(url, options, runner_opts)
|
||||||
|
{:ok, :ignorable} -> {:error, :unsuitable_for_download}
|
||||||
|
err -> err
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp recoverable_errors do
|
defp recoverable_errors do
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
|
|||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
redownloadable_media = Media.list_redownloadable_media_items()
|
upgradable_media = Media.list_upgradeable_media_items()
|
||||||
Logger.info("Redownloading #{length(redownloadable_media)} media items")
|
Logger.info("Redownloading #{length(upgradable_media)} media items")
|
||||||
|
|
||||||
Enum.each(redownloadable_media, fn media_item ->
|
Enum.each(upgradable_media, fn media_item ->
|
||||||
MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
|
MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Oban.Worker,
|
use Oban.Worker,
|
||||||
queue: :local_metadata,
|
queue: :local_data,
|
||||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||||
tags: ["media_item", "local_metadata"]
|
tags: ["media_item", "local_data"]
|
||||||
|
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -20,14 +23,53 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do
|
|||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
cullable_media = Media.list_cullable_media_items()
|
cull_cullable_media_items()
|
||||||
|
delete_media_items_from_before_cutoff()
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cull_cullable_media_items do
|
||||||
|
cullable_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.require_assoc(:source)
|
||||||
|
|> where(^MediaQuery.cullable())
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
|
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
|
||||||
|
|
||||||
Enum.each(cullable_media, fn media_item ->
|
Enum.each(cullable_media, fn media_item ->
|
||||||
|
# Setting `prevent_download` does what it says on the tin, but `culled_at` is purely informational.
|
||||||
|
# We don't actually do anything with that in terms of queries and it gets set to nil if the media item
|
||||||
|
# gets re-downloaded.
|
||||||
Media.delete_media_files(media_item, %{
|
Media.delete_media_files(media_item, %{
|
||||||
prevent_download: true,
|
prevent_download: true,
|
||||||
culled_at: DateTime.utc_now()
|
culled_at: DateTime.utc_now()
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NOTE: Since this is a date and not a datetime, we can't add logic to have to-the-minute
|
||||||
|
# comparison like we can with retention periods. We can only compare to the day.
|
||||||
|
defp delete_media_items_from_before_cutoff do
|
||||||
|
deletable_media =
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.require_assoc(:source)
|
||||||
|
|> where(^MediaQuery.deletable_based_on_source_cutoff())
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
Logger.info("Deleting #{length(deletable_media)} media items that are from before the source cutoff")
|
||||||
|
|
||||||
|
Enum.each(deletable_media, fn media_item ->
|
||||||
|
# Note that I'm not setting `prevent_download` on the media_item here.
|
||||||
|
# That's because cutoff_date can easily change and it's a valid behavior to re-download older
|
||||||
|
# media items if the cutoff_date changes.
|
||||||
|
# Download is ultimately prevented because `MediaQuery.pending()` only returns media items
|
||||||
|
# from after the cutoff date (among other things), so it's not like the media will just immediately
|
||||||
|
# be re-downloaded.
|
||||||
|
Media.delete_media_files(media_item, %{
|
||||||
|
culled_at: DateTime.utc_now()
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
defmodule Pinchflat.Downloading.QualityOptionBuilder do
|
||||||
|
@moduledoc """
|
||||||
|
A standalone builder module for building quality-related options for yt-dlp to download media.
|
||||||
|
|
||||||
|
Currently exclusively used in DownloadOptionBuilder since this logic is too complex to just
|
||||||
|
place in the main module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Settings
|
||||||
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the quality-related options for yt-dlp to download media based on the given media profile
|
||||||
|
|
||||||
|
Includes things like container, preferred format/codec, and audio track options.
|
||||||
|
"""
|
||||||
|
def build(%MediaProfile{preferred_resolution: :audio, media_container: container} = media_profile) do
|
||||||
|
acodec = Settings.get!(:audio_codec_preference)
|
||||||
|
|
||||||
|
[
|
||||||
|
:extract_audio,
|
||||||
|
format_sort: "+acodec:#{acodec}",
|
||||||
|
audio_format: container || "best",
|
||||||
|
format: build_format_string(media_profile)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def build(%MediaProfile{preferred_resolution: resolution_atom, media_container: container} = media_profile) do
|
||||||
|
vcodec = Settings.get!(:video_codec_preference)
|
||||||
|
acodec = Settings.get!(:audio_codec_preference)
|
||||||
|
{resolution_string, _} = resolution_atom |> Atom.to_string() |> Integer.parse()
|
||||||
|
|
||||||
|
[
|
||||||
|
# Since Plex doesn't support reading metadata from MKV
|
||||||
|
remux_video: container || "mp4",
|
||||||
|
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}",
|
||||||
|
format: build_format_string(media_profile)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_format_string(%MediaProfile{preferred_resolution: :audio, audio_track: audio_track}) do
|
||||||
|
if audio_track do
|
||||||
|
"bestaudio[#{build_format_modifier(audio_track)}]/bestaudio/best"
|
||||||
|
else
|
||||||
|
"bestaudio/best"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_format_string(%MediaProfile{audio_track: audio_track}) do
|
||||||
|
if audio_track do
|
||||||
|
"bestvideo+bestaudio[#{build_format_modifier(audio_track)}]/bestvideo*+bestaudio/best"
|
||||||
|
else
|
||||||
|
"bestvideo*+bestaudio/best"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reminder to self: this conflicts with `--extractor-args "youtube:lang=<LANG>"`
|
||||||
|
# since that will translate the format_notes as well, which means they may not match.
|
||||||
|
# At least that's what happens now - worth a re-check if I have to come back to this
|
||||||
|
defp build_format_modifier("original"), do: "format_note*=original"
|
||||||
|
defp build_format_modifier("default"), do: "format_note*='(default)'"
|
||||||
|
# This uses the carat to anchor the language to the beginning of the string
|
||||||
|
# since that's what's needed to match `en` to `en-US` and `en-GB`, etc. The user
|
||||||
|
# can always specify the full language code if they want.
|
||||||
|
defp build_format_modifier(language_code), do: "language^=#{language_code}"
|
||||||
|
end
|
||||||
@@ -11,13 +11,27 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.YoutubeRss
|
alias Pinchflat.FastIndexing.YoutubeRss
|
||||||
alias Pinchflat.FastIndexing.YoutubeApi
|
alias Pinchflat.FastIndexing.YoutubeApi
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Kicks off a new fast indexing task for a source. This will delete any existing fast indexing
|
||||||
|
tasks for the source before starting a new one.
|
||||||
|
|
||||||
|
Returns {:ok, %Task{}}
|
||||||
|
"""
|
||||||
|
def kickoff_indexing_task(%Source{} = source) do
|
||||||
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: true)
|
||||||
|
FastIndexingWorker.kickoff_with_task(source)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading
|
Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading
|
||||||
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
|
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
|
||||||
@@ -27,6 +41,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
downloaded_.
|
downloaded_.
|
||||||
"""
|
"""
|
||||||
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
|
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||||
|
# The media_profile is needed to determine the quality options to _then_ determine a more
|
||||||
|
# accurate predicted filepath
|
||||||
|
source = Repo.preload(source, [:media_profile])
|
||||||
|
|
||||||
{:ok, media_ids} = get_recent_media_ids(source)
|
{:ok, media_ids} = get_recent_media_ids(source)
|
||||||
existing_media_items = list_media_items_by_media_id_for(source, media_ids)
|
existing_media_items = list_media_items_by_media_id_for(source, media_ids)
|
||||||
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
||||||
@@ -68,7 +86,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
|||||||
defp create_media_item_from_media_id(source, media_id) do
|
defp create_media_item_from_media_id(source, media_id) do
|
||||||
url = "https://www.youtube.com/watch?v=#{media_id}"
|
url = "https://www.youtube.com/watch?v=#{media_id}"
|
||||||
|
|
||||||
case YtDlpMedia.get_media_attributes(url) do
|
command_opts =
|
||||||
|
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
|
||||||
|
DownloadOptionBuilder.build_quality_options_for(source)
|
||||||
|
|
||||||
|
case YtDlpMedia.get_media_attributes(url, command_opts, use_cookies: source.use_cookies) do
|
||||||
{:ok, media_attrs} ->
|
{:ok, media_attrs} ->
|
||||||
Media.create_media_item_from_backend_attrs(source, media_attrs)
|
Media.create_media_item_from_backend_attrs(source, media_attrs)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
@behaviour UserScriptCommandRunner
|
@behaviour UserScriptCommandRunner
|
||||||
|
|
||||||
@event_types [
|
@event_types [
|
||||||
|
:app_init,
|
||||||
|
:media_pre_download,
|
||||||
:media_downloaded,
|
:media_downloaded,
|
||||||
:media_deleted
|
:media_deleted
|
||||||
]
|
]
|
||||||
@@ -22,24 +24,25 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
|
|
||||||
This function will succeed in almost all cases, even if the user script command
|
This function will succeed in almost all cases, even if the user script command
|
||||||
failed - this is because I don't want bad scripts to stop the whole process.
|
failed - this is because I don't want bad scripts to stop the whole process.
|
||||||
If something fails, it'll be logged.
|
If something fails, it'll be logged and returned BUT the tuple will always
|
||||||
|
start with {:ok, ...}.
|
||||||
|
|
||||||
The only things that can cause a true failure are passing in an invalid event
|
The only things that can cause a true failure are passing in an invalid event
|
||||||
type or if the passed data cannot be encoded into JSON - both indicative of
|
type or if the passed data cannot be encoded into JSON - both indicative of
|
||||||
failures in the development process.
|
failures in the development process.
|
||||||
|
|
||||||
Returns :ok
|
Returns {:ok, :no_executable} | {:ok, output, exit_code}
|
||||||
"""
|
"""
|
||||||
@impl UserScriptCommandRunner
|
@impl UserScriptCommandRunner
|
||||||
def run(event_type, encodable_data) when event_type in @event_types do
|
def run(event_type, encodable_data) when event_type in @event_types do
|
||||||
case backend_executable() do
|
case backend_executable() do
|
||||||
{:ok, :no_executable} ->
|
{:ok, :no_executable} ->
|
||||||
:ok
|
{:ok, :no_executable}
|
||||||
|
|
||||||
{:ok, executable_path} ->
|
{:ok, executable_path} ->
|
||||||
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
||||||
|
|
||||||
{_output, _exit_code} =
|
{output, exit_code} =
|
||||||
CliUtils.wrap_cmd(
|
CliUtils.wrap_cmd(
|
||||||
executable_path,
|
executable_path,
|
||||||
[to_string(event_type), encoded_data],
|
[to_string(event_type), encoded_data],
|
||||||
@@ -47,7 +50,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
logging_arg_override: "[suppressed]"
|
logging_arg_override: "[suppressed]"
|
||||||
)
|
)
|
||||||
|
|
||||||
:ok
|
{:ok, output, exit_code}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
|||||||
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
||||||
{:ok, filepath}
|
{:ok, filepath}
|
||||||
else
|
else
|
||||||
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
|
Logger.info("User scripts lifecyle file either not present or is empty. Skipping.")
|
||||||
|
|
||||||
{:ok, :no_executable}
|
{:ok, :no_executable}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
defmodule Pinchflat.Media.FileSyncing do
|
||||||
|
@moduledoc """
|
||||||
|
Functions for ensuring file state is accurately reflected in the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Utils.MapUtils
|
||||||
|
alias Pinchflat.Media.MediaItem
|
||||||
|
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes files that are no longer needed by a media item.
|
||||||
|
|
||||||
|
This means that if a media item has been updated, the old and new versions
|
||||||
|
can be passed and any files that are no longer needed will be deleted.
|
||||||
|
|
||||||
|
An example is a video that gets its quality upgraded and its name changes
|
||||||
|
between original download and re-download. The old file will exist on-disk
|
||||||
|
with the old name but the database entry will point to the new file. This
|
||||||
|
function can be used to delete the old file in this case.
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
def delete_outdated_files(old_media_item, new_media_item) do
|
||||||
|
non_subtitle_keys = MediaItem.filepath_attributes() -- [:subtitle_filepaths]
|
||||||
|
|
||||||
|
old_non_subtitles = Map.take(old_media_item, non_subtitle_keys)
|
||||||
|
old_subtitles = MapUtils.from_nested_list(old_media_item.subtitle_filepaths)
|
||||||
|
new_non_subtitles = Map.take(new_media_item, non_subtitle_keys)
|
||||||
|
new_subtitles = MapUtils.from_nested_list(new_media_item.subtitle_filepaths)
|
||||||
|
|
||||||
|
handle_file_deletion(old_non_subtitles, new_non_subtitles)
|
||||||
|
handle_file_deletion(old_subtitles, new_subtitles)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Nillifies any media item filepaths that don't exist on disk for a list of media items
|
||||||
|
|
||||||
|
returns [%MediaItem{}]
|
||||||
|
"""
|
||||||
|
def sync_file_presence_on_disk(media_items) do
|
||||||
|
Enum.map(media_items, fn media_item ->
|
||||||
|
new_attributes = sync_media_item_files(media_item)
|
||||||
|
# Doing this one-by-one instead of batching since this process
|
||||||
|
# can take time and a batch could let MediaItem state get out of sync
|
||||||
|
{:ok, updated_media_item} = Media.update_media_item(media_item, new_attributes)
|
||||||
|
|
||||||
|
updated_media_item
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_file_deletion(old_attributes, new_attributes) do
|
||||||
|
# The logic:
|
||||||
|
# - A file should only be deleted if it exists and the new file is different
|
||||||
|
# - The new attributes are the ones we're interested in keeping
|
||||||
|
# - If the old attributes have a key that doesn't exist in the new attributes, don't touch it.
|
||||||
|
# This is good for archiving but may be unpopular for other users so this may change.
|
||||||
|
|
||||||
|
Enum.each(new_attributes, fn {key, new_filepath} ->
|
||||||
|
old_filepath = Map.get(old_attributes, key)
|
||||||
|
files_have_changed = old_filepath && new_filepath && old_filepath != new_filepath
|
||||||
|
files_exist_on_disk = files_have_changed && File.exists?(old_filepath) && File.exists?(new_filepath)
|
||||||
|
|
||||||
|
if files_exist_on_disk && !FSUtils.filepaths_reference_same_file?(old_filepath, new_filepath) do
|
||||||
|
FSUtils.delete_file_and_remove_empty_directories(old_filepath)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_media_item_files(media_item) do
|
||||||
|
non_subtitle_keys = MediaItem.filepath_attributes() -- [:subtitle_filepaths]
|
||||||
|
subtitle_keys = MapUtils.from_nested_list(media_item.subtitle_filepaths)
|
||||||
|
non_subtitles = Map.take(media_item, non_subtitle_keys)
|
||||||
|
|
||||||
|
# This one is checking for the negative (ie: only update if the file doesn't exist)
|
||||||
|
new_non_subtitle_attrs =
|
||||||
|
Enum.reduce(non_subtitles, %{}, fn {key, filepath}, acc ->
|
||||||
|
if filepath && File.exists?(filepath), do: acc, else: Map.put(acc, key, nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# This one is checking for the positive (ie: only update if the file exists)
|
||||||
|
# This is because subtitles, being an array type in the DB, are most easily updated
|
||||||
|
# by a full replacement rather than finding the actual diff
|
||||||
|
new_subtitle_attrs =
|
||||||
|
Enum.reduce(subtitle_keys, [], fn {key, filepath}, acc ->
|
||||||
|
if filepath && File.exists?(filepath), do: acc ++ [[key, filepath]], else: acc
|
||||||
|
end)
|
||||||
|
|
||||||
|
Map.put(new_non_subtitle_attrs, :subtitle_filepaths, new_subtitle_attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule Pinchflat.Media.FileSyncingWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_data,
|
||||||
|
tags: ["sources", "local_data"]
|
||||||
|
|
||||||
|
alias __MODULE__
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Tasks
|
||||||
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Media.FileSyncing
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the source file syncing worker.
|
||||||
|
|
||||||
|
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def kickoff_with_task(source, opts \\ []) do
|
||||||
|
%{id: source.id}
|
||||||
|
|> FileSyncingWorker.new(opts)
|
||||||
|
|> Tasks.create_job_with_task(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a profile and optionally deletes its files
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
||||||
|
source = Repo.preload(Sources.get_source!(source_id), :media_items)
|
||||||
|
|
||||||
|
FileSyncing.sync_file_presence_on_disk(source.media_items)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,6 +15,9 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||||
|
|
||||||
|
# Some fields should only be set on insert and not on update.
|
||||||
|
@fields_to_drop_on_update [:playlist_index]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of media_items.
|
Returns the list of media_items.
|
||||||
|
|
||||||
@@ -25,21 +28,10 @@ defmodule Pinchflat.Media do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of media_items that are cullable based on the retention period
|
Returns a list of media_items that are upgradeable based on the redownload delay
|
||||||
of the source they belong to.
|
of the media_profile their source belongs to. In this context, upgradeable means
|
||||||
|
that it's been long enough since upload that the video may be in a higher quality
|
||||||
Returns [%MediaItem{}, ...]
|
or have better sponsorblock segments (or similar).
|
||||||
"""
|
|
||||||
def list_cullable_media_items do
|
|
||||||
MediaQuery.new()
|
|
||||||
|> MediaQuery.require_assoc(:source)
|
|
||||||
|> where(^MediaQuery.cullable())
|
|
||||||
|> Repo.all()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns a list of media_items that are redownloadable based on the redownload delay
|
|
||||||
of the media_profile their source belongs to.
|
|
||||||
|
|
||||||
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
||||||
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
||||||
@@ -52,10 +44,10 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Returns [%MediaItem{}, ...]
|
Returns [%MediaItem{}, ...]
|
||||||
"""
|
"""
|
||||||
def list_redownloadable_media_items do
|
def list_upgradeable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.require_assoc(:media_profile)
|
|> MediaQuery.require_assoc(:media_profile)
|
||||||
|> where(^MediaQuery.redownloadable())
|
|> where(^MediaQuery.upgradeable())
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,8 +134,6 @@ defmodule Pinchflat.Media do
|
|||||||
"""
|
"""
|
||||||
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
def create_media_item_from_backend_attrs(source, media_attrs_struct) do
|
||||||
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
|
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
|
||||||
# Some fields should only be set on insert and not on update.
|
|
||||||
fields_to_drop_on_update = [:playlist_index]
|
|
||||||
|
|
||||||
%MediaItem{}
|
%MediaItem{}
|
||||||
|> MediaItem.changeset(attrs)
|
|> MediaItem.changeset(attrs)
|
||||||
@@ -151,7 +141,7 @@ defmodule Pinchflat.Media do
|
|||||||
on_conflict: [
|
on_conflict: [
|
||||||
set:
|
set:
|
||||||
attrs
|
attrs
|
||||||
|> Map.drop(fields_to_drop_on_update)
|
|> Map.drop(@fields_to_drop_on_update)
|
||||||
|> Map.to_list()
|
|> Map.to_list()
|
||||||
],
|
],
|
||||||
conflict_target: [:source_id, :media_id]
|
conflict_target: [:source_id, :media_id]
|
||||||
@@ -164,8 +154,10 @@ defmodule Pinchflat.Media do
|
|||||||
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def update_media_item(%MediaItem{} = media_item, attrs) do
|
def update_media_item(%MediaItem{} = media_item, attrs) do
|
||||||
|
update_attrs = Map.drop(attrs, @fields_to_drop_on_update)
|
||||||
|
|
||||||
media_item
|
media_item
|
||||||
|> MediaItem.changeset(attrs)
|
|> MediaItem.changeset(update_attrs)
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -182,7 +174,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
if delete_files do
|
if delete_files do
|
||||||
{:ok, _} = do_delete_media_files(media_item)
|
{:ok, _} = do_delete_media_files(media_item)
|
||||||
:ok = run_user_script(:media_deleted, media_item)
|
run_user_script(:media_deleted, media_item)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should delete these no matter what
|
# Should delete these no matter what
|
||||||
@@ -205,7 +197,7 @@ defmodule Pinchflat.Media do
|
|||||||
|
|
||||||
Tasks.delete_tasks_for(media_item)
|
Tasks.delete_tasks_for(media_item)
|
||||||
{:ok, _} = do_delete_media_files(media_item)
|
{:ok, _} = do_delete_media_files(media_item)
|
||||||
:ok = run_user_script(:media_deleted, media_item)
|
run_user_script(:media_deleted, media_item)
|
||||||
|
|
||||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
:uploaded_at,
|
:uploaded_at,
|
||||||
:upload_date_index,
|
:upload_date_index,
|
||||||
:duration_seconds,
|
:duration_seconds,
|
||||||
|
:predicted_media_filepath,
|
||||||
# these fields are captured only on download
|
# these fields are captured only on download
|
||||||
:media_downloaded_at,
|
:media_downloaded_at,
|
||||||
:media_filepath,
|
:media_filepath,
|
||||||
@@ -76,6 +77,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||||||
field :duration_seconds, :integer
|
field :duration_seconds, :integer
|
||||||
field :playlist_index, :integer, default: 0
|
field :playlist_index, :integer, default: 0
|
||||||
|
|
||||||
|
field :predicted_media_filepath, :string
|
||||||
field :media_filepath, :string
|
field :media_filepath, :string
|
||||||
field :media_size_bytes, :integer
|
field :media_size_bytes, :integer
|
||||||
field :thumbnail_filepath, :string
|
field :thumbnail_filepath, :string
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
||||||
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
|
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
|
||||||
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
|
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
|
||||||
def culled, do: dynamic([mi], not is_nil(mi.culled_at))
|
|
||||||
def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at))
|
def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at))
|
||||||
def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
|
def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
|
||||||
|
|
||||||
@@ -76,12 +75,20 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def meets_min_and_max_duration do
|
||||||
|
dynamic(
|
||||||
|
[mi, source],
|
||||||
|
(is_nil(source.min_duration_seconds) or fragment("duration_seconds >= ?", source.min_duration_seconds)) and
|
||||||
|
(is_nil(source.max_duration_seconds) or fragment("duration_seconds <= ?", source.max_duration_seconds))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def past_retention_period do
|
def past_retention_period do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi, source],
|
[mi, source],
|
||||||
fragment("""
|
fragment("""
|
||||||
IFNULL(retention_period_days, 0) > 0 AND
|
IFNULL(retention_period_days, 0) > 0 AND
|
||||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
DATETIME(media_downloaded_at, '+' || retention_period_days || ' day') < DATETIME('now')
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -93,8 +100,8 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
# downloaded_at minus the redownload_delay_days is before the upload date
|
# downloaded_at minus the redownload_delay_days is before the upload date
|
||||||
fragment("""
|
fragment("""
|
||||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||||
DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND
|
DATE('now', '-' || redownload_delay_days || ' day') > DATE(uploaded_at) AND
|
||||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
|
DATE(media_downloaded_at, '-' || redownload_delay_days || ' day') < DATE(uploaded_at)
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -108,6 +115,15 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deletable_based_on_source_cutoff do
|
||||||
|
dynamic(
|
||||||
|
[mi, source],
|
||||||
|
^downloaded() and
|
||||||
|
not (^upload_date_after_source_cutoff()) and
|
||||||
|
not (^culling_prevented())
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def pending do
|
def pending do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi],
|
[mi],
|
||||||
@@ -115,16 +131,16 @@ defmodule Pinchflat.Media.MediaQuery do
|
|||||||
not (^download_prevented()) and
|
not (^download_prevented()) and
|
||||||
^upload_date_after_source_cutoff() and
|
^upload_date_after_source_cutoff() and
|
||||||
^format_matching_profile_preference() and
|
^format_matching_profile_preference() and
|
||||||
^matches_source_title_regex()
|
^matches_source_title_regex() and
|
||||||
|
^meets_min_and_max_duration()
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redownloadable do
|
def upgradeable do
|
||||||
dynamic(
|
dynamic(
|
||||||
[mi, source],
|
[mi, source],
|
||||||
^downloaded() and
|
^downloaded() and
|
||||||
not (^download_prevented()) and
|
not (^download_prevented()) and
|
||||||
not (^culled()) and
|
|
||||||
not (^redownloaded()) and
|
not (^redownloaded()) and
|
||||||
^past_redownload_delay()
|
^past_redownload_delay()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
|
|
||||||
Returns binary() | nil
|
Returns binary() | nil
|
||||||
"""
|
"""
|
||||||
def download_and_store_thumbnail_for(database_record) do
|
def download_and_store_thumbnail_for(media_item_with_preloads) do
|
||||||
yt_dlp_filepath = generate_filepath_for(database_record, "thumbnail.%(ext)s")
|
yt_dlp_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.%(ext)s")
|
||||||
real_filepath = generate_filepath_for(database_record, "thumbnail.jpg")
|
real_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.jpg")
|
||||||
|
command_opts = [output: yt_dlp_filepath]
|
||||||
|
addl_opts = [use_cookies: media_item_with_preloads.source.use_cookies]
|
||||||
|
|
||||||
case YtDlpMedia.download_thumbnail(database_record.original_url, output: yt_dlp_filepath) do
|
case YtDlpMedia.download_thumbnail(media_item_with_preloads.original_url, command_opts, addl_opts) do
|
||||||
{:ok, _} -> real_filepath
|
{:ok, _} -> real_filepath
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
|
|
||||||
defp determine_series_directory(source) do
|
defp determine_series_directory(source) do
|
||||||
output_path = DownloadOptionBuilder.build_output_path_for(source)
|
output_path = DownloadOptionBuilder.build_output_path_for(source)
|
||||||
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, output: output_path)
|
runner_opts = [output: output_path]
|
||||||
|
addl_opts = [use_cookies: source.use_cookies]
|
||||||
|
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, runner_opts, addl_opts)
|
||||||
|
|
||||||
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
|
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
|
||||||
{:ok, series_directory} -> series_directory
|
{:ok, series_directory} -> series_directory
|
||||||
@@ -119,7 +121,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
base_opts ++ [:write_thumbnail, playlist_items: 1]
|
base_opts ++ [:write_thumbnail, playlist_items: 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
MediaCollection.get_source_metadata(source.original_url, opts)
|
MediaCollection.get_source_metadata(source.original_url, opts, use_cookies: source.use_cookies)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp tmp_directory do
|
defp tmp_directory do
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
sponsorblock_categories
|
sponsorblock_categories
|
||||||
shorts_behaviour
|
shorts_behaviour
|
||||||
livestream_behaviour
|
livestream_behaviour
|
||||||
|
audio_track
|
||||||
preferred_resolution
|
preferred_resolution
|
||||||
|
media_container
|
||||||
redownload_delay_days
|
redownload_delay_days
|
||||||
|
marked_for_deletion_at
|
||||||
)a
|
)a
|
||||||
|
|
||||||
@required_fields ~w(name output_path_template)a
|
@required_fields ~w(name output_path_template)a
|
||||||
@@ -63,7 +66,11 @@ 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(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
field :audio_track, :string
|
||||||
|
field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1440p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
||||||
|
field :media_container, :string, default: nil
|
||||||
|
|
||||||
|
field :marked_for_deletion_at, :utc_datetime
|
||||||
|
|
||||||
has_many :sources, Source
|
has_many :sources, Source
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule Pinchflat.Profiles.MediaProfileDeletionWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_data,
|
||||||
|
tags: ["media_profiles", "local_data"]
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias __MODULE__
|
||||||
|
alias Pinchflat.Profiles
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the profile deletion worker. Does not attach it to a task like `kickoff_with_task/2`
|
||||||
|
since deletion also cancels all tasks for the profile
|
||||||
|
|
||||||
|
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def kickoff(profile, job_args \\ %{}, job_opts \\ []) do
|
||||||
|
%{id: profile.id}
|
||||||
|
|> Map.merge(job_args)
|
||||||
|
|> MediaProfileDeletionWorker.new(job_opts)
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a profile and optionally deletes its files
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"id" => profile_id} = args}) do
|
||||||
|
delete_files = Map.get(args, "delete_files", false)
|
||||||
|
profile = Profiles.get_media_profile!(profile_id)
|
||||||
|
|
||||||
|
Profiles.delete_media_profile(profile, delete_files: delete_files)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
defmodule Pinchflat.Profiles.ProfilesQuery do
|
||||||
|
@moduledoc """
|
||||||
|
Query helpers for the Profiles 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.Profiles.MediaProfile
|
||||||
|
|
||||||
|
# This allows the module to be aliased and query methods to be used
|
||||||
|
# all in one go
|
||||||
|
# usage: use Pinchflat.Profiles.ProfilesQuery
|
||||||
|
defmacro __using__(_opts) do
|
||||||
|
quote do
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias unquote(__MODULE__)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def new do
|
||||||
|
MediaProfile
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -29,6 +29,8 @@ defmodule Pinchflat.Release do
|
|||||||
[
|
[
|
||||||
"/config",
|
"/config",
|
||||||
"/downloads",
|
"/downloads",
|
||||||
|
"/etc/yt-dlp",
|
||||||
|
"/etc/yt-dlp/plugins",
|
||||||
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),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
alias Pinchflat.YtDlp.MediaCollection
|
alias Pinchflat.YtDlp.MediaCollection
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||||
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
@@ -24,13 +25,28 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
Starts tasks for indexing a source's media regardless of the source's indexing
|
Starts tasks for indexing a source's media regardless of the source's indexing
|
||||||
frequency. It's assumed the caller will check for indexing frequency.
|
frequency. It's assumed the caller will check for indexing frequency.
|
||||||
|
|
||||||
Returns {:ok, %Task{}}.
|
Returns {:ok, %Task{}}
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
job_offset_seconds = calculate_job_offset_seconds(source)
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
|
||||||
|
|
||||||
MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts)
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
||||||
|
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true)
|
||||||
|
|
||||||
|
MediaCollectionIndexingWorker.kickoff_with_task(source, job_args, job_opts ++ [schedule_in: job_offset_seconds])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A helper method to delete all indexing-related tasks for a source.
|
||||||
|
Optionally, you can include executing tasks in the deletion process.
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
def delete_indexing_tasks(%Source{} = source, opts \\ []) do
|
||||||
|
include_executing = Keyword.get(opts, :include_executing, false)
|
||||||
|
|
||||||
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: include_executing)
|
||||||
|
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: include_executing)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -56,6 +72,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
Returns [%MediaItem{} | %Ecto.Changeset{}]
|
Returns [%MediaItem{} | %Ecto.Changeset{}]
|
||||||
"""
|
"""
|
||||||
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
||||||
|
# The media_profile is needed to determine the quality options to _then_ determine a more
|
||||||
|
# accurate predicted filepath
|
||||||
|
source = Repo.preload(source, [:media_profile])
|
||||||
# See the method definition below for more info on how file watchers work
|
# See the method definition below for more info on how file watchers work
|
||||||
# (important reading if you're not familiar with it)
|
# (important reading if you're not familiar with it)
|
||||||
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
|
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
|
||||||
@@ -94,7 +113,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
{:ok, pid} = FileFollowerServer.start_link()
|
{:ok, pid} = FileFollowerServer.start_link()
|
||||||
|
|
||||||
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
||||||
result = MediaCollection.get_media_attributes_for_collection(source.original_url, file_listener_handler: handler)
|
|
||||||
|
command_opts =
|
||||||
|
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
|
||||||
|
DownloadOptionBuilder.build_quality_options_for(source)
|
||||||
|
|
||||||
|
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
|
||||||
|
result = MediaCollection.get_media_attributes_for_collection(source.original_url, command_opts, runner_opts)
|
||||||
|
|
||||||
FileFollowerServer.stop(pid)
|
FileFollowerServer.stop(pid)
|
||||||
|
|
||||||
@@ -131,4 +156,14 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
changeset
|
changeset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Find the difference between the current time and the last time the source was indexed
|
||||||
|
defp calculate_job_offset_seconds(%Source{last_indexed_at: nil}), do: 0
|
||||||
|
|
||||||
|
defp calculate_job_offset_seconds(source) do
|
||||||
|
offset_seconds = DateTime.diff(DateTime.utc_now(), source.last_indexed_at, :second)
|
||||||
|
index_frequency_seconds = source.index_frequency_minutes * 60
|
||||||
|
|
||||||
|
max(0, index_frequency_seconds - offset_seconds)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
@allowed_fields ~w(
|
@allowed_fields ~w(
|
||||||
|
enabled
|
||||||
collection_name
|
collection_name
|
||||||
collection_id
|
collection_id
|
||||||
collection_type
|
collection_type
|
||||||
@@ -27,6 +28,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
series_directory
|
series_directory
|
||||||
index_frequency_minutes
|
index_frequency_minutes
|
||||||
fast_index
|
fast_index
|
||||||
|
use_cookies
|
||||||
download_media
|
download_media
|
||||||
last_indexed_at
|
last_indexed_at
|
||||||
original_url
|
original_url
|
||||||
@@ -35,6 +37,9 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
title_filter_regex
|
title_filter_regex
|
||||||
media_profile_id
|
media_profile_id
|
||||||
output_path_template_override
|
output_path_template_override
|
||||||
|
marked_for_deletion_at
|
||||||
|
min_duration_seconds
|
||||||
|
max_duration_seconds
|
||||||
)a
|
)a
|
||||||
|
|
||||||
# Expensive API calls are made when a source is inserted/updated so
|
# Expensive API calls are made when a source is inserted/updated so
|
||||||
@@ -60,6 +65,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
)a
|
)a
|
||||||
|
|
||||||
schema "sources" do
|
schema "sources" do
|
||||||
|
field :enabled, :boolean, default: true
|
||||||
# This is _not_ used as the primary key or internally in the database
|
# This is _not_ used as the primary key or internally in the database
|
||||||
# relations. This is only used to prevent an enumeration attack on the streaming
|
# relations. This is only used to prevent an enumeration attack on the streaming
|
||||||
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
|
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
|
||||||
@@ -72,6 +78,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
|
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
|
||||||
field :index_frequency_minutes, :integer, default: 60 * 24
|
field :index_frequency_minutes, :integer, default: 60 * 24
|
||||||
field :fast_index, :boolean, default: false
|
field :fast_index, :boolean, default: false
|
||||||
|
field :use_cookies, :boolean, default: false
|
||||||
field :download_media, :boolean, default: true
|
field :download_media, :boolean, default: true
|
||||||
field :last_indexed_at, :utc_datetime
|
field :last_indexed_at, :utc_datetime
|
||||||
# Only download media items that were published after this date
|
# Only download media items that were published after this date
|
||||||
@@ -81,12 +88,17 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
field :title_filter_regex, :string
|
field :title_filter_regex, :string
|
||||||
field :output_path_template_override, :string
|
field :output_path_template_override, :string
|
||||||
|
|
||||||
|
field :min_duration_seconds, :integer
|
||||||
|
field :max_duration_seconds, :integer
|
||||||
|
|
||||||
field :series_directory, :string
|
field :series_directory, :string
|
||||||
field :nfo_filepath, :string
|
field :nfo_filepath, :string
|
||||||
field :poster_filepath, :string
|
field :poster_filepath, :string
|
||||||
field :fanart_filepath, :string
|
field :fanart_filepath, :string
|
||||||
field :banner_filepath, :string
|
field :banner_filepath, :string
|
||||||
|
|
||||||
|
field :marked_for_deletion_at, :utc_datetime
|
||||||
|
|
||||||
belongs_to :media_profile, MediaProfile
|
belongs_to :media_profile, MediaProfile
|
||||||
|
|
||||||
has_one :metadata, SourceMetadata, on_replace: :update
|
has_one :metadata, SourceMetadata, on_replace: :update
|
||||||
@@ -113,6 +125,7 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
||||||
|> validate_required(required_fields)
|
|> validate_required(required_fields)
|
||||||
|> validate_title_regex()
|
|> validate_title_regex()
|
||||||
|
|> validate_min_and_max_durations()
|
||||||
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
||||||
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
||||||
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|
||||||
@@ -159,6 +172,17 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
|
|
||||||
defp validate_title_regex(changeset), do: changeset
|
defp validate_title_regex(changeset), do: changeset
|
||||||
|
|
||||||
|
defp validate_min_and_max_durations(changeset) do
|
||||||
|
min_duration = get_change(changeset, :min_duration_seconds)
|
||||||
|
max_duration = get_change(changeset, :max_duration_seconds)
|
||||||
|
|
||||||
|
case {min_duration, max_duration} do
|
||||||
|
{min, max} when is_nil(min) or is_nil(max) -> changeset
|
||||||
|
{min, max} when min >= max -> add_error(changeset, :max_duration_seconds, "must be greater than minumum duration")
|
||||||
|
_ -> changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defimpl Jason.Encoder, for: Source do
|
defimpl Jason.Encoder, for: Source do
|
||||||
def encode(value, opts) do
|
def encode(value, opts) do
|
||||||
value
|
value
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule Pinchflat.Sources.SourceDeletionWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :local_data,
|
||||||
|
tags: ["sources", "local_data"]
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias __MODULE__
|
||||||
|
alias Pinchflat.Sources
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the source deletion worker. Does not attach it to a task like `kickoff_with_task/2`
|
||||||
|
since deletion also cancels all tasks for the source
|
||||||
|
|
||||||
|
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def kickoff(source, job_args \\ %{}, job_opts \\ []) do
|
||||||
|
%{id: source.id}
|
||||||
|
|> Map.merge(job_args)
|
||||||
|
|> SourceDeletionWorker.new(job_opts)
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a source and optionally deletes its files
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: %{"id" => source_id} = args}) do
|
||||||
|
delete_files = Map.get(args, "delete_files", false)
|
||||||
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
|
Sources.delete_source(source, delete_files: delete_files)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,8 +15,8 @@ defmodule Pinchflat.Sources do
|
|||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
alias Pinchflat.Utils.FilesystemUtils
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
|
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -180,11 +180,19 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp add_source_details_to_changeset(source, changeset) do
|
defp add_source_details_to_changeset(source, changeset) do
|
||||||
case MediaCollection.get_source_details(changeset.changes.original_url) do
|
use_cookies = Ecto.Changeset.get_field(changeset, :use_cookies)
|
||||||
|
|
||||||
|
case MediaCollection.get_source_details(changeset.changes.original_url, [], use_cookies: use_cookies) do
|
||||||
{:ok, source_details} ->
|
{:ok, source_details} ->
|
||||||
add_source_details_by_collection_type(source, changeset, source_details)
|
add_source_details_by_collection_type(source, changeset, source_details)
|
||||||
|
|
||||||
{:error, runner_error, _status_code} ->
|
err ->
|
||||||
|
runner_error =
|
||||||
|
case err do
|
||||||
|
{:error, error_msg, _status_code} -> error_msg
|
||||||
|
{:error, error_msg} -> error_msg
|
||||||
|
end
|
||||||
|
|
||||||
Ecto.Changeset.add_error(
|
Ecto.Changeset.add_error(
|
||||||
changeset,
|
changeset,
|
||||||
:original_url,
|
:original_url,
|
||||||
@@ -247,19 +255,40 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# If the source is NOT new (ie: updated) and the download_media flag has changed,
|
# If the source is new (ie: not persisted), do nothing
|
||||||
|
defp maybe_handle_media_tasks(%{data: %{__meta__: %{state: state}}}, _source) when state != :loaded do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the source is NOT new (ie: updated),
|
||||||
# enqueue or dequeue media download tasks as necessary.
|
# enqueue or dequeue media download tasks as necessary.
|
||||||
defp maybe_handle_media_tasks(changeset, source) do
|
defp maybe_handle_media_tasks(changeset, source) do
|
||||||
case {changeset.data, changeset.changes} do
|
current_changes = changeset.changes
|
||||||
{%{__meta__: %{state: :loaded}}, %{download_media: true}} ->
|
applied_changes = Ecto.Changeset.apply_changes(changeset)
|
||||||
|
|
||||||
|
# We need both current_changes and applied_changes to determine
|
||||||
|
# the course of action to take. For example, we only care if a source is supposed
|
||||||
|
# to be `enabled` or not - we don't care if that information comes from the
|
||||||
|
# current changes or if that's how it already was in the database.
|
||||||
|
# Rephrased, we're essentially using it in place of `get_field/2`
|
||||||
|
case {current_changes, applied_changes} do
|
||||||
|
{%{download_media: true}, %{enabled: true}} ->
|
||||||
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
||||||
|
|
||||||
{%{__meta__: %{state: :loaded}}, %{download_media: false}} ->
|
{%{enabled: true}, %{download_media: true}} ->
|
||||||
|
DownloadingHelpers.enqueue_pending_download_tasks(source)
|
||||||
|
|
||||||
|
{%{download_media: false}, _} ->
|
||||||
|
DownloadingHelpers.dequeue_pending_download_tasks(source)
|
||||||
|
|
||||||
|
{%{enabled: false}, _} ->
|
||||||
DownloadingHelpers.dequeue_pending_download_tasks(source)
|
DownloadingHelpers.dequeue_pending_download_tasks(source)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_run_indexing_task(changeset, source) do
|
defp maybe_run_indexing_task(changeset, source) do
|
||||||
@@ -293,13 +322,22 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_update_slow_indexing_task(changeset, source) do
|
defp maybe_update_slow_indexing_task(changeset, source) do
|
||||||
case changeset.changes do
|
# See comment in `maybe_handle_media_tasks` as to why we need these
|
||||||
%{index_frequency_minutes: mins} when mins > 0 ->
|
current_changes = changeset.changes
|
||||||
|
applied_changes = Ecto.Changeset.apply_changes(changeset)
|
||||||
|
|
||||||
|
case {current_changes, applied_changes} do
|
||||||
|
{%{index_frequency_minutes: mins}, %{enabled: true}} when mins > 0 ->
|
||||||
SlowIndexingHelpers.kickoff_indexing_task(source)
|
SlowIndexingHelpers.kickoff_indexing_task(source)
|
||||||
|
|
||||||
%{index_frequency_minutes: _} ->
|
{%{enabled: true}, %{index_frequency_minutes: mins}} when mins > 0 ->
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
SlowIndexingHelpers.kickoff_indexing_task(source)
|
||||||
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker")
|
|
||||||
|
{%{index_frequency_minutes: _}, _} ->
|
||||||
|
SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
|
||||||
|
|
||||||
|
{%{enabled: false}, _} ->
|
||||||
|
SlowIndexingHelpers.delete_indexing_tasks(source, include_executing: true)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
@@ -307,13 +345,25 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_update_fast_indexing_task(changeset, source) do
|
defp maybe_update_fast_indexing_task(changeset, source) do
|
||||||
case changeset.changes do
|
# See comment in `maybe_handle_media_tasks` as to why we need these
|
||||||
%{fast_index: true} ->
|
current_changes = changeset.changes
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
applied_changes = Ecto.Changeset.apply_changes(changeset)
|
||||||
FastIndexingWorker.kickoff_with_task(source)
|
|
||||||
|
|
||||||
%{fast_index: false} ->
|
# This technically could be simplified since `maybe_update_slow_indexing_task`
|
||||||
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
|
# has some overlap re: deleting pending tasks, but I'm keeping it separate
|
||||||
|
# for clarity and explicitness.
|
||||||
|
case {current_changes, applied_changes} do
|
||||||
|
{%{fast_index: true}, %{enabled: true}} ->
|
||||||
|
FastIndexingHelpers.kickoff_indexing_task(source)
|
||||||
|
|
||||||
|
{%{enabled: true}, %{fast_index: true}} ->
|
||||||
|
FastIndexingHelpers.kickoff_indexing_task(source)
|
||||||
|
|
||||||
|
{%{fast_index: false}, _} ->
|
||||||
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: true)
|
||||||
|
|
||||||
|
{%{enabled: false}, _} ->
|
||||||
|
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker", include_executing: true)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|||||||
@@ -53,20 +53,6 @@ defmodule Pinchflat.Tasks do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the list of pending tasks for a given record type and ID. Optionally allows you to specify
|
|
||||||
which worker to include.
|
|
||||||
|
|
||||||
Returns [%Task{}, ...]
|
|
||||||
"""
|
|
||||||
def list_pending_tasks_for(record, worker_name \\ nil) do
|
|
||||||
list_tasks_for(
|
|
||||||
record,
|
|
||||||
worker_name,
|
|
||||||
[:available, :scheduled, :retryable]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single task.
|
Gets a single task.
|
||||||
|
|
||||||
@@ -127,13 +113,13 @@ defmodule Pinchflat.Tasks do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes all tasks attached to a given record, cancelling any attached jobs.
|
Deletes all tasks attached to a given record, cancelling any attached jobs.
|
||||||
Optionally allows you to specify which worker to include.
|
Optionally allows you to specify which worker and job states to include.
|
||||||
|
|
||||||
Returns :ok
|
Returns :ok
|
||||||
"""
|
"""
|
||||||
def delete_tasks_for(record, worker_name \\ nil) do
|
def delete_tasks_for(record, worker_name \\ nil, job_states \\ Oban.Job.states()) do
|
||||||
record
|
record
|
||||||
|> list_tasks_for(worker_name)
|
|> list_tasks_for(worker_name, job_states)
|
||||||
|> Enum.each(&delete_task/1)
|
|> Enum.each(&delete_task/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -143,10 +129,12 @@ defmodule Pinchflat.Tasks do
|
|||||||
|
|
||||||
Returns :ok
|
Returns :ok
|
||||||
"""
|
"""
|
||||||
def delete_pending_tasks_for(record, worker_name \\ nil) do
|
def delete_pending_tasks_for(record, worker_name \\ nil, opts \\ []) do
|
||||||
record
|
include_executing = Keyword.get(opts, :include_executing, false)
|
||||||
|> list_pending_tasks_for(worker_name)
|
base_job_states = [:available, :scheduled, :retryable]
|
||||||
|> Enum.each(&delete_task/1)
|
job_states = if include_executing, do: base_job_states ++ [:executing], else: base_job_states
|
||||||
|
|
||||||
|
delete_tasks_for(record, worker_name, job_states)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ defmodule Pinchflat.Utils.FilesystemUtils do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if two filepaths reference the same file.
|
||||||
|
|
||||||
|
Useful if you have a relative and absolute filepath and want to be sure they're the same file.
|
||||||
|
Also works with symlinks.
|
||||||
|
|
||||||
|
Returns boolean()
|
||||||
|
"""
|
||||||
|
def filepaths_reference_same_file?(filepath_1, filepath_2) do
|
||||||
|
{:ok, stat_1} = File.stat(filepath_1)
|
||||||
|
{:ok, stat_2} = File.stat(filepath_2)
|
||||||
|
|
||||||
|
identifier_1 = "#{stat_1.major_device}:#{stat_1.minor_device}:#{stat_1.inode}"
|
||||||
|
identifier_2 = "#{stat_2.major_device}:#{stat_2.minor_device}:#{stat_2.inode}"
|
||||||
|
|
||||||
|
identifier_1 == identifier_2
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Generates a temporary file and returns its path. The file is empty and has the given type.
|
Generates a temporary file and returns its path. The file is empty and has the given type.
|
||||||
Generates all the directories in the path if they don't exist.
|
Generates all the directories in the path if they don't exist.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
defmodule Pinchflat.Utils.MapUtils do
|
||||||
|
@moduledoc """
|
||||||
|
Utility methods for working with maps
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts a nested list of 2-element tuples or lists into a map.
|
||||||
|
|
||||||
|
Returns map()
|
||||||
|
"""
|
||||||
|
def from_nested_list(list) do
|
||||||
|
Enum.reduce(list, %{}, fn
|
||||||
|
[key, value], acc -> Map.put(acc, key, value)
|
||||||
|
{key, value}, acc -> Map.put(acc, key, value)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -41,4 +41,15 @@ defmodule Pinchflat.Utils.StringUtils do
|
|||||||
string
|
string
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Wraps a string in double braces. Useful as a UI helper now that
|
||||||
|
LiveView 1.0.0 allows `{}` for interpolation so now we can't use braces
|
||||||
|
directly in the view.
|
||||||
|
|
||||||
|
Returns binary()
|
||||||
|
"""
|
||||||
|
def double_brace(string) do
|
||||||
|
"{{ #{string} }}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
- :output_filepath - the path to save the output to. If not provided, a temporary
|
- :output_filepath - the path to save the output to. If not provided, a temporary
|
||||||
file will be created and used. Useful for if you need a reference to the file
|
file will be created and used. Useful for if you need a reference to the file
|
||||||
for a file watcher.
|
for a file watcher.
|
||||||
|
- :use_cookies - if true, will add a cookie file to the command options. Will not
|
||||||
|
attach a cookie file if the user hasn't set one up.
|
||||||
|
|
||||||
Returns {:ok, binary()} | {:error, output, status}.
|
Returns {:ok, binary()} | {:error, output, status}.
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +30,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
|
|
||||||
output_filepath = generate_output_filepath(addl_opts)
|
output_filepath = generate_output_filepath(addl_opts)
|
||||||
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
|
||||||
user_configured_opts = cookie_file_options()
|
user_configured_opts = cookie_file_options(addl_opts)
|
||||||
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
||||||
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
|
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
|
||||||
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
||||||
@@ -78,7 +80,14 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp cookie_file_options do
|
defp cookie_file_options(addl_opts) do
|
||||||
|
case Keyword.get(addl_opts, :use_cookies) do
|
||||||
|
true -> add_cookie_file()
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_cookie_file do
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
filename_options_map = %{cookies: "cookies.txt"}
|
filename_options_map = %{cookies: "cookies.txt"}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:livestream,
|
:livestream,
|
||||||
:short_form_content,
|
:short_form_content,
|
||||||
:uploaded_at,
|
:uploaded_at,
|
||||||
:duration_seconds
|
:duration_seconds,
|
||||||
|
:predicted_media_filepath
|
||||||
]
|
]
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
@@ -23,7 +24,8 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
:short_form_content,
|
:short_form_content,
|
||||||
:uploaded_at,
|
:uploaded_at,
|
||||||
:duration_seconds,
|
:duration_seconds,
|
||||||
:playlist_index
|
:playlist_index,
|
||||||
|
:predicted_media_filepath
|
||||||
]
|
]
|
||||||
|
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
@@ -37,9 +39,9 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def download(url, command_opts \\ [], addl_opts \\ []) do
|
def download(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
opts = [:no_simulate] ++ command_opts
|
all_command_opts = [:no_simulate] ++ command_opts
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j", addl_opts),
|
with {:ok, output} <- backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts),
|
||||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, parsed_json}
|
{:ok, parsed_json}
|
||||||
else
|
else
|
||||||
@@ -47,31 +49,50 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Determines if the media at the given URL is ready to be downloaded.
|
||||||
|
Common examples of non-downloadable media are upcoming or in-progress live streams.
|
||||||
|
|
||||||
|
Returns {:ok, :downloadable | :ignorable} | {:error, any}
|
||||||
|
"""
|
||||||
|
def get_downloadable_status(url) do
|
||||||
|
case backend_runner().run(url, [:simulate, :skip_download], "%(.{live_status})j") do
|
||||||
|
{:ok, output} ->
|
||||||
|
output
|
||||||
|
|> Phoenix.json_library().decode!()
|
||||||
|
|> parse_downloadable_status()
|
||||||
|
|
||||||
|
err ->
|
||||||
|
err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Downloads a thumbnail for a single piece of media. Usually used for
|
Downloads a thumbnail for a single piece of media. Usually used for
|
||||||
downloading thumbnails for internal use
|
downloading thumbnails for internal use
|
||||||
|
|
||||||
Returns {:ok, ""} | {:error, any, ...}.
|
Returns {:ok, ""} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def download_thumbnail(url, command_opts \\ []) do
|
def download_thumbnail(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
|
all_command_opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
|
||||||
|
|
||||||
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
|
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
|
||||||
# we aren't downloading the main media file
|
# we aren't downloading the main media file
|
||||||
backend_runner().run(url, opts, "after_move:%()j")
|
backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a map representing the media at the given URL.
|
Returns a map representing the media at the given URL.
|
||||||
|
Optionally takes a list of additional command options to pass to yt-dlp
|
||||||
|
or configuration-related options to pass to the runner.
|
||||||
|
|
||||||
Returns {:ok, %Media{}} | {:error, any, ...}.
|
Returns {:ok, %Media{}} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_media_attributes(url) do
|
def get_media_attributes(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
all_command_opts = [:simulate, :skip_download] ++ command_opts
|
||||||
command_opts = [:simulate, :skip_download]
|
|
||||||
output_template = indexing_output_template()
|
output_template = indexing_output_template()
|
||||||
|
|
||||||
case runner.run(url, command_opts, output_template) do
|
case backend_runner().run(url, all_command_opts, output_template, addl_opts) do
|
||||||
{:ok, output} ->
|
{:ok, output} ->
|
||||||
output
|
output
|
||||||
|> Phoenix.json_library().decode!()
|
|> Phoenix.json_library().decode!()
|
||||||
@@ -87,9 +108,11 @@ 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.
|
||||||
|
|
||||||
NOTE: playlist_index is really only useful for playlists that will never change their order.
|
NOTE: playlist_index is really only useful for playlists that will never change their order.
|
||||||
|
NOTE: I've switched back to `original_url` (from `webpage_url`) since it's started indicating
|
||||||
|
if something is a short via the URL again
|
||||||
"""
|
"""
|
||||||
def indexing_output_template do
|
def indexing_output_template do
|
||||||
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
|
"%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index,filename})j"
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -103,17 +126,18 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
media_id: response["id"],
|
media_id: response["id"],
|
||||||
title: response["title"],
|
title: response["title"],
|
||||||
description: response["description"],
|
description: response["description"],
|
||||||
original_url: response["webpage_url"],
|
original_url: response["original_url"],
|
||||||
livestream: !!response["was_live"],
|
livestream: !!response["live_status"] && response["live_status"] != "not_live",
|
||||||
duration_seconds: response["duration"] && round(response["duration"]),
|
duration_seconds: response["duration"] && round(response["duration"]),
|
||||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
short_form_content: response["original_url"] && short_form_content?(response),
|
||||||
uploaded_at: response["upload_date"] && parse_uploaded_at(response),
|
uploaded_at: response["upload_date"] && parse_uploaded_at(response),
|
||||||
playlist_index: response["playlist_index"] || 0
|
playlist_index: response["playlist_index"] || 0,
|
||||||
|
predicted_media_filepath: response["filename"]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp short_form_content?(response) do
|
defp short_form_content?(response) do
|
||||||
if String.contains?(response["webpage_url"], "/shorts/") do
|
if String.contains?(response["original_url"], "/shorts/") do
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
# Sometimes shorts are returned without /shorts/ in the URL,
|
# Sometimes shorts are returned without /shorts/ in the URL,
|
||||||
@@ -124,7 +148,7 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
#
|
#
|
||||||
# These don't fail if duration or aspect_ratio are missing
|
# These don't fail if duration or aspect_ratio are missing
|
||||||
# due to Elixir's comparison semantics
|
# due to Elixir's comparison semantics
|
||||||
response["duration"] <= 60 && response["aspect_ratio"] < 0.8
|
response["duration"] <= 60 && response["aspect_ratio"] <= 0.85
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -140,6 +164,16 @@ defmodule Pinchflat.YtDlp.Media do
|
|||||||
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
|
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
|
||||||
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||||
|
|
||||||
|
defp parse_downloadable_status(response) do
|
||||||
|
case response["live_status"] do
|
||||||
|
status when status in ["is_live", "is_upcoming", "post_live"] -> {:ok, :ignorable}
|
||||||
|
status when status in ["was_live", "not_live"] -> {:ok, :downloadable}
|
||||||
|
# This preserves my tenuous support for non-youtube sources.
|
||||||
|
nil -> {:ok, :downloadable}
|
||||||
|
_ -> {:error, "Unknown live status: #{response["live_status"]}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -11,29 +11,34 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of maps representing the media in the collection.
|
Returns a list of maps representing the media in the collection.
|
||||||
|
Optionally takes a list of additional command options to pass to yt-dlp
|
||||||
|
or configuration-related options to pass to the runner.
|
||||||
|
|
||||||
Options:
|
Runner Options:
|
||||||
- :file_listener_handler - a function that will be called with the path to the
|
- :file_listener_handler - a function that will be called with the path to the
|
||||||
file that will be written to when yt-dlp is done. This is useful for
|
file that will be written to when yt-dlp is done. This is useful for
|
||||||
setting up a file watcher to know when the file is ready to be read.
|
setting up a file watcher to know when the file is ready to be read.
|
||||||
|
- :use_cookies - whether or not to use user-provided cookies when fetching the media details
|
||||||
|
|
||||||
Returns {:ok, [map()]} | {:error, any, ...}.
|
Returns {:ok, [map()]} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_media_attributes_for_collection(url, addl_opts \\ []) do
|
def get_media_attributes_for_collection(url, command_opts \\ [], addl_opts \\ []) do
|
||||||
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||||
# `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, :no_warnings]
|
all_command_opts = [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings] ++ command_opts
|
||||||
|
use_cookies = Keyword.get(addl_opts, :use_cookies, false)
|
||||||
output_template = YtDlpMedia.indexing_output_template()
|
output_template = YtDlpMedia.indexing_output_template()
|
||||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
||||||
|
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
|
||||||
|
|
||||||
if file_listener_handler do
|
if file_listener_handler do
|
||||||
file_listener_handler.(output_filepath)
|
file_listener_handler.(output_filepath)
|
||||||
end
|
end
|
||||||
|
|
||||||
case runner.run(url, command_opts, output_template, output_filepath: output_filepath) do
|
case runner.run(url, all_command_opts, output_template, runner_opts) do
|
||||||
{:ok, output} ->
|
{:ok, output} ->
|
||||||
parsed_lines =
|
parsed_lines =
|
||||||
output
|
output
|
||||||
@@ -64,7 +69,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_source_details(source_url, addl_opts \\ []) do
|
def get_source_details(source_url, command_opts \\ [], addl_opts \\ []) do
|
||||||
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
|
||||||
# the first video has not released yet (ie: is a premier). We don't care about
|
# the first video has not released yet (ie: is a premier). We don't care about
|
||||||
# available formats since we're just getting the source details
|
# available formats since we're just getting the source details
|
||||||
@@ -75,13 +80,14 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
playlist_end: 1
|
playlist_end: 1
|
||||||
]
|
]
|
||||||
|
|
||||||
command_opts = default_opts ++ addl_opts
|
all_command_opts = default_opts ++ command_opts
|
||||||
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(source_url, command_opts, output_template),
|
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts),
|
||||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, format_source_details(parsed_json)}
|
{:ok, format_source_details(parsed_json)}
|
||||||
else
|
else
|
||||||
|
{:error, %Jason.DecodeError{}} -> {:error, "Error decoding JSON response"}
|
||||||
err -> err
|
err -> err
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -109,14 +115,14 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_source_metadata(source_url, addl_opts \\ [playlist_items: 0]) do
|
def get_source_metadata(source_url, command_opts, addl_opts \\ []) do
|
||||||
# This only validates that the `playlist_items` key is present. It's otherwise unused
|
# This only validates that the `playlist_items` key is present. It's otherwise unused
|
||||||
_playlist_items = Keyword.fetch!(addl_opts, :playlist_items)
|
_playlist_items = Keyword.fetch!(command_opts, :playlist_items)
|
||||||
|
|
||||||
opts = [:skip_download] ++ addl_opts
|
all_command_opts = [:skip_download] ++ command_opts
|
||||||
output_template = "playlist:%()j"
|
output_template = "playlist:%()j"
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
|
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts),
|
||||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, parsed_json}
|
{:ok, parsed_json}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ defmodule PinchflatWeb do
|
|||||||
import PinchflatWeb.CustomComponents.TextComponents
|
import PinchflatWeb.CustomComponents.TextComponents
|
||||||
import PinchflatWeb.CustomComponents.TableComponents
|
import PinchflatWeb.CustomComponents.TableComponents
|
||||||
import PinchflatWeb.CustomComponents.ButtonComponents
|
import PinchflatWeb.CustomComponents.ButtonComponents
|
||||||
|
import Pinchflat.Utils.StringUtils, only: [double_brace: 1]
|
||||||
|
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Utils.StringUtils
|
alias Pinchflat.Utils.StringUtils
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id={"#{@id}-content"}>
|
<div id={"#{@id}-content"}>
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,9 +126,9 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
]}>
|
]}>
|
||||||
<main>
|
<main>
|
||||||
<h5 :if={@title} class="mb-2 text-lg font-bold">
|
<h5 :if={@title} class="mb-2 text-lg font-bold">
|
||||||
<%= @title %>
|
{@title}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="mt-2 text-md leading-5 opacity-80"><%= msg %></p>
|
<p class="mt-2 text-md leading-5 opacity-80">{msg}</p>
|
||||||
</main>
|
</main>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -208,9 +208,9 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
def simple_form(assigns) do
|
def simple_form(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||||
<%= render_slot(@inner_block, f) %>
|
{render_slot(@inner_block, f)}
|
||||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||||
<%= render_slot(action, f) %>
|
{render_slot(action, f)}
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
"""
|
"""
|
||||||
@@ -297,11 +297,11 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
class={["rounded focus:ring-0", @inputclass]}
|
class={["rounded focus:ring-0", @inputclass]}
|
||||||
{@rest}
|
{@rest}
|
||||||
/>
|
/>
|
||||||
<%= @label %>
|
{@label}
|
||||||
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
|
||||||
</label>
|
</label>
|
||||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -310,7 +310,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
~H"""
|
~H"""
|
||||||
<div phx-feedback-for={@name}>
|
<div phx-feedback-for={@name}>
|
||||||
<.label for={@id}>
|
<.label for={@id}>
|
||||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
{@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
|
||||||
</.label>
|
</.label>
|
||||||
<section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1">
|
<section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1">
|
||||||
<div :for={{option_name, option_value} <- @options} class="flex items-center">
|
<div :for={{option_name, option_value} <- @options} class="flex items-center">
|
||||||
@@ -323,12 +323,12 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]}
|
class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]}
|
||||||
/>
|
/>
|
||||||
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
|
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
|
||||||
<%= option_name %>
|
{option_name}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -340,14 +340,15 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div x-data={"{ enabled: #{@checked}}"}>
|
<div x-data={"{ enabled: #{@checked} }"} class="" phx-update="ignore" id={"#{@id}-wrapper"}>
|
||||||
<.label for={@id}>
|
<.label :if={@label} for={@id}>
|
||||||
<%= @label %>
|
{@label}
|
||||||
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
|
||||||
</.label>
|
</.label>
|
||||||
<div class="relative">
|
<div class="relative flex flex-col">
|
||||||
<input type="hidden" id={@id} name={@name} x-bind:value="enabled" {@rest} />
|
<input type="hidden" id={@id} name={@name} x-bind:value="enabled" {@rest} />
|
||||||
<div class="inline-block cursor-pointer" @click="enabled = !enabled">
|
<%!-- This triggers a `change` event on the hidden input when the toggle is clicked --%>
|
||||||
|
<div class="inline-block cursor-pointer" @click={"enabled = !enabled; dispatchFor('#{@id}', 'change')"}>
|
||||||
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div>
|
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div>
|
||||||
<div
|
<div
|
||||||
x-bind:class="enabled && '!right-1 !translate-x-full'"
|
x-bind:class="enabled && '!right-1 !translate-x-full'"
|
||||||
@@ -358,8 +359,8 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -369,7 +370,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
~H"""
|
~H"""
|
||||||
<div phx-feedback-for={@name}>
|
<div phx-feedback-for={@name}>
|
||||||
<.label :if={@label} for={@id}>
|
<.label :if={@label} for={@id}>
|
||||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
{@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
|
||||||
</.label>
|
</.label>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<select
|
<select
|
||||||
@@ -384,13 +385,13 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
multiple={@multiple}
|
multiple={@multiple}
|
||||||
{@rest}
|
{@rest}
|
||||||
>
|
>
|
||||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
<option :if={@prompt} value="">{@prompt}</option>
|
||||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||||
</select>
|
</select>
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -399,7 +400,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
~H"""
|
~H"""
|
||||||
<div phx-feedback-for={@name}>
|
<div phx-feedback-for={@name}>
|
||||||
<.label for={@id}>
|
<.label for={@id}>
|
||||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
{@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
|
||||||
</.label>
|
</.label>
|
||||||
<textarea
|
<textarea
|
||||||
id={@id}
|
id={@id}
|
||||||
@@ -413,8 +414,8 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -424,7 +425,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
~H"""
|
~H"""
|
||||||
<div phx-feedback-for={@name}>
|
<div phx-feedback-for={@name}>
|
||||||
<.label for={@id}>
|
<.label for={@id}>
|
||||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
{@label}<span :if={@label_suffix} class="text-xs text-bodydark">{@label_suffix}</span>
|
||||||
</.label>
|
</.label>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input
|
<input
|
||||||
@@ -441,10 +442,10 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
/>
|
/>
|
||||||
<%= render_slot(@input_append) %>
|
{render_slot(@input_append)}
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
<.help :if={@help}>{if @html_help, do: Phoenix.HTML.raw(@help), else: @help}</.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -457,7 +458,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
def help(assigns) do
|
def help(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<p class="mt-1 text-sm leading-5">
|
<p class="mt-1 text-sm leading-5">
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</p>
|
</p>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -471,7 +472,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
def label(assigns) do
|
def label(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<label for={@for} class="mt-5 mb-2 inline-block text-md font-medium text-black dark:text-white">
|
<label for={@for} class="mt-5 mb-2 inline-block text-md font-medium text-black dark:text-white">
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</label>
|
</label>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -485,7 +486,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
~H"""
|
~H"""
|
||||||
<p class="mt-1 mb-5 flex gap-3 text-md leading-6 text-rose-600 phx-no-feedback:hidden">
|
<p class="mt-1 mb-5 flex gap-3 text-md leading-6 text-rose-600 phx-no-feedback:hidden">
|
||||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</p>
|
</p>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -504,13 +505,13 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</h1>
|
</h1>
|
||||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||||
<%= render_slot(@subtitle) %>
|
{render_slot(@subtitle)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
<div class="flex-none">{render_slot(@actions)}</div>
|
||||||
</header>
|
</header>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -550,9 +551,9 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<table class="w-[40rem] mt-11 sm:w-full">
|
<table class="w-[40rem] mt-11 sm:w-full">
|
||||||
<thead class="text-sm text-left leading-6 text-zinc-500">
|
<thead class="text-sm text-left leading-6 text-zinc-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
|
||||||
<th :if={@action != []} class="relative p-0 pb-4">
|
<th :if={@action != []} class="relative p-0 pb-4">
|
||||||
<span class="sr-only"><%= gettext("Actions") %></span>
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -570,7 +571,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<div class="block py-4 pr-6">
|
<div class="block py-4 pr-6">
|
||||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||||
<%= render_slot(col, @row_item.(row)) %>
|
{render_slot(col, @row_item.(row))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -578,7 +579,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||||
<span :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
|
<span :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
|
||||||
<%= render_slot(action, @row_item.(row)) %>
|
{render_slot(action, @row_item.(row))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -607,8 +608,8 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<div class="mt-2 mb-14">
|
<div class="mt-2 mb-14">
|
||||||
<dl class="-my-4 divide-y dark:divide-strokedark">
|
<dl class="-my-4 divide-y dark:divide-strokedark">
|
||||||
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||||
<dt class="w-1/4 flex-none dark:text-white"><%= item.title %></dt>
|
<dt class="w-1/4 flex-none dark:text-white">{item.title}</dt>
|
||||||
<dd class="dark:text-white"><%= render_slot(item) %></dd>
|
<dd class="dark:text-white">{render_slot(item)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -643,12 +644,12 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
~H"""
|
~H"""
|
||||||
<ul>
|
<ul>
|
||||||
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
|
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
|
||||||
<strong><%= k %>:</strong>
|
<strong>{k}:</strong>
|
||||||
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
|
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
|
||||||
<%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
|
<%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
|
||||||
<TextComponents.inline_link href={v}><%= v %></TextComponents.inline_link>
|
<TextComponents.inline_link href={v}>{v}</TextComponents.inline_link>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= v %>
|
{v}
|
||||||
<% end %>
|
<% end %>
|
||||||
</code>
|
</code>
|
||||||
</li>
|
</li>
|
||||||
@@ -671,7 +672,7 @@ defmodule PinchflatWeb.CoreComponents do
|
|||||||
<div class="mt-16">
|
<div class="mt-16">
|
||||||
<.link href={@href} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
|
<.link href={@href} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
|
||||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||||||
disabled={@disabled}
|
disabled={@disabled}
|
||||||
{@rest}
|
{@rest}
|
||||||
>
|
>
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -66,7 +66,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||||||
"font-medium text-white hover:bg-opacity-95"
|
"font-medium text-white hover:bg-opacity-95"
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<%= @text %>
|
{@text}
|
||||||
<CoreComponents.icon
|
<CoreComponents.icon
|
||||||
name="hero-chevron-down"
|
name="hero-chevron-down"
|
||||||
class="fill-current duration-200 ease-linear mt-1"
|
class="fill-current duration-200 ease-linear mt-1"
|
||||||
@@ -81,7 +81,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||||||
<ul class="flex flex-col">
|
<ul class="flex flex-col">
|
||||||
<li :for={option <- @option}>
|
<li :for={option <- @option}>
|
||||||
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
|
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
|
||||||
<%= render_slot(option) %>
|
{render_slot(option)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -126,7 +126,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||||||
>
|
>
|
||||||
<span class="border-light absolute -top-1 left-1/2 -z-10 h-2 w-2 -translate-x-1/2 rotate-45 rounded-sm bg-meta-4">
|
<span class="border-light absolute -top-1 left-1/2 -z-10 h-2 w-2 -translate-x-1/2 rotate-45 rounded-sm bg-meta-4">
|
||||||
</span>
|
</span>
|
||||||
<span><%= @tooltip %></span>
|
<span>{@tooltip}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -34,16 +34,16 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
|||||||
x-bind:class={"openTab === '#{tab.id}' ? 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>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
|
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
|
||||||
<%= render_slot(@tab_append) %>
|
{render_slot(@tab_append)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="mt-4 min-h-60">
|
<div class="mt-4 min-h-60">
|
||||||
<div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-meta-4">
|
<tr class="text-left bg-meta-4">
|
||||||
<th :for={col <- @col} class="px-4 py-4 font-medium 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>
|
||||||
@@ -45,7 +45,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
|||||||
col[:class]
|
col[:class]
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<%= render_slot(col, @row_item.(row)) %>
|
{render_slot(col, @row_item.(row))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
def inline_code(assigns) do
|
def inline_code(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<code class="inline-block text-sm font-mono text-gray bg-boxdark rounded-md p-0.5 mx-0.5 text-nowrap">
|
<code class="inline-block text-sm font-mono text-gray bg-boxdark rounded-md p-0.5 mx-0.5 text-nowrap">
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</code>
|
</code>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -26,7 +26,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
def inline_link(assigns) do
|
def inline_link(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
|
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</.link>
|
</.link>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -41,7 +41,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
def subtle_link(assigns) do
|
def subtle_link(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.link href={@href} target={@target} class="underline decoration-bodydark decoration-1 hover:decoration-white">
|
<.link href={@href} target={@target} class="underline decoration-bodydark decoration-1 hover:decoration-white">
|
||||||
<%= render_slot(@inner_block) %>
|
{render_slot(@inner_block)}
|
||||||
</.link>
|
</.link>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -62,20 +62,27 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a block of text with each line broken into a separate span.
|
Renders a block of text with each line broken into a separate span and links highlighted.
|
||||||
"""
|
"""
|
||||||
attr :text, :string, required: true
|
attr :text, :string, required: true
|
||||||
|
|
||||||
def break_on_newline(assigns) do
|
def render_description(assigns) do
|
||||||
broken_text =
|
formatted_text =
|
||||||
assigns.text
|
Regex.split(~r{https?://\S+}, assigns.text, include_captures: true)
|
||||||
|> String.split("\n", trim: false)
|
|> Enum.map(fn
|
||||||
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
|
"http" <> _ = url ->
|
||||||
|
Phoenix.HTML.Tag.content_tag(:a, url, class: "text-blue-500 hover:text-blue-300", href: url, target: "_blank")
|
||||||
|
|
||||||
assigns = Map.put(assigns, :text, broken_text)
|
text ->
|
||||||
|
text
|
||||||
|
|> String.split("\n", trim: false)
|
||||||
|
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
|
||||||
|
end)
|
||||||
|
|
||||||
|
assigns = Map.put(assigns, :text, formatted_text)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<span><%= @text %></span>
|
<span>{@text}</span>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -91,7 +98,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
assigns = Map.put(assigns, :timezone, timezone)
|
assigns = Map.put(assigns, :timezone, timezone)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
<time>{Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format)}</time>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -102,7 +109,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
|
|
||||||
def localized_number(assigns) do
|
def localized_number(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span>
|
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}>{@number}</span>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,7 +122,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
|
|
||||||
def pluralize(assigns) do
|
def pluralize(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= @word %><%= if @count == 1, do: "", else: @suffix %>
|
{@word}{if @count == 1, do: "", else: @suffix}
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ defmodule PinchflatWeb.Layouts do
|
|||||||
x-on:click="selected = !selected"
|
x-on:click="selected = !selected"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2.5">
|
<span class="flex items-center gap-2.5">
|
||||||
<.icon name={@icon} /> <%= @text %>
|
<.icon name={@icon} /> {@text}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-bodydark2">
|
<span class="text-bodydark2">
|
||||||
<.icon name="hero-chevron-up" x-bind:class="{ 'rotate-180': selected }" />
|
<.icon name="hero-chevron-down" x-bind:class="{ 'rotate-180': selected }" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ defmodule PinchflatWeb.Layouts do
|
|||||||
@class
|
@class
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<.icon :if={@icon} name={@icon} /> <%= @text %>
|
<.icon :if={@icon} name={@icon} /> {@text}
|
||||||
</.link>
|
</.link>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
|
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
|
||||||
<.flash_group flash={@flash} />
|
<.flash_group flash={@flash} />
|
||||||
<%= @inner_content %>
|
{@inner_content}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
|
<div class="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
|
||||||
<.flash_group flash={@flash} />
|
<.flash_group flash={@flash} />
|
||||||
<%= @inner_content %>
|
{@inner_content}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,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 <%= 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"
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||||
yt-dlp <%= Settings.get!(:yt_dlp_version) %>
|
yt-dlp {Settings.get!(:yt_dlp_version)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ defmodule Pinchflat.UpgradeButtonLive do
|
|||||||
|
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<form id="upgradeForm" phx-change="check_matching_text" phx-hook="supressEnterSubmission">
|
<form id="upgradeForm" phx-change="check_matching_text" phx-hook="supress-enter-submission">
|
||||||
<.input type="text" name="unlock-pro-textbox" value="" />
|
<.input type="text" name="unlock-pro-textbox" value="" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,6 @@
|
|||||||
into the text box and press the button.
|
into the text box and press the button.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%= live_render(@conn, Pinchflat.UpgradeButtonLive) %>
|
{live_render(@conn, Pinchflat.UpgradeButtonLive)}
|
||||||
</section>
|
</section>
|
||||||
</.modal>
|
</.modal>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content={get_csrf_token()} />
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
<.live_title>
|
<.live_title>
|
||||||
<%= assigns[:page_title] || "Pinchflat" %>
|
{assigns[:page_title] || "Pinchflat"}
|
||||||
</.live_title>
|
</.live_title>
|
||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||||
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
|
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
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")}
|
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
|
||||||
>
|
>
|
||||||
<%= @inner_content %>
|
{@inner_content}
|
||||||
|
|
||||||
<.donate_modal conn={@conn} />
|
<.donate_modal conn={@conn} />
|
||||||
<template x-if="!proEnabled && !onboarding">
|
<template x-if="!proEnabled && !onboarding">
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
defmodule PinchflatWeb.ErrorHTML do
|
defmodule PinchflatWeb.ErrorHTML do
|
||||||
use PinchflatWeb, :html
|
use PinchflatWeb, :html
|
||||||
|
|
||||||
# If you want to customize your error pages,
|
embed_templates "error_html/*"
|
||||||
# uncomment the embed_templates/1 call below
|
|
||||||
# and add pages to the error directory:
|
|
||||||
#
|
|
||||||
# * lib/pinchflat_web/controllers/error_html/404.html.heex
|
|
||||||
# * lib/pinchflat_web/controllers/error_html/500.html.heex
|
|
||||||
#
|
|
||||||
# embed_templates "error_html/*"
|
|
||||||
|
|
||||||
# The default is to render a plain text page based on
|
# The default is to render a plain text page based on
|
||||||
# the template name. For example, "404.html" becomes
|
# the template name. For example, "404.html" becomes
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<section>
|
||||||
|
<h2 class="text-title-md2 font-bold text-white">404 (not found)</h2>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<section>
|
||||||
|
<h2 class="text-title-md2 font-bold text-white">Internal Server Error</h2>
|
||||||
|
<p class="text-body-md text-white mt-2">
|
||||||
|
This shouldn't happen! Please make a
|
||||||
|
<.inline_link href="https://github.com/kieraneglin/pinchflat/issues/new/choose">GitHub issue</.inline_link>
|
||||||
|
with the following information:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="list-disc ml-8 mb-8">
|
||||||
|
<li>What you were doing when you saw this page</li>
|
||||||
|
<li>
|
||||||
|
Your system details and logs from
|
||||||
|
<.inline_link href={~p"/app_info"}>app info</.inline_link>
|
||||||
|
</li>
|
||||||
|
<li>All the information in the textarea below (use select all + copy)</li>
|
||||||
|
</ul>
|
||||||
|
<textarea class="w-full min-h-96 font-mono inline-block rounded-lg" readonly>
|
||||||
|
**Status**:
|
||||||
|
`<%= if Map.has_key?(assigns, :status), do: @status, else: "" %>`
|
||||||
|
|
||||||
|
**Reason**:
|
||||||
|
`<%= if Map.has_key?(assigns, :reason), do: inspect(@reason), else: "" %>`
|
||||||
|
|
||||||
|
**Stacktrace**:
|
||||||
|
```
|
||||||
|
<%= if Map.has_key?(assigns, :stack), do: Exception.format_stacktrace(@stack), else: "" %>
|
||||||
|
```
|
||||||
|
</textarea>
|
||||||
|
</section>
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
|
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_item))}`,
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="mb-6 flex gap-3 flex-row items-center">
|
<div class="mb-6 flex gap-3 flex-row items-center">
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
Editing "<%= StringUtils.truncate(@media_item.title, 35) %>"
|
Editing "{StringUtils.truncate(@media_item.title, 35)}"
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<%= if media_type(@media_item) == :video do %>
|
<%= if media_type(@media_item) == :video do %>
|
||||||
<video controls class="max-h-96 w-full">
|
<video controls class="max-h-128 w-full">
|
||||||
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
|
<source src={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"} type="video/mp4" />
|
||||||
Your browser does not support the video element.
|
Your browser does not support the video element.
|
||||||
</video>
|
</video>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if media_type(@media_item) == :audio do %>
|
<%= if media_type(@media_item) == :audio do %>
|
||||||
<audio controls class="w-full">
|
<audio controls class="w-full">
|
||||||
<source src={~p"/media/#{@media_item.uuid}/stream"} type="audio/mpeg" />
|
<source src={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"} type="audio/mpeg" />
|
||||||
Your browser does not support the audio element.
|
Your browser does not support the audio element.
|
||||||
</audio>
|
</audio>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
<%= StringUtils.truncate(@media_item.title, 35) %>
|
{StringUtils.truncate(@media_item.title, 35)}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,25 +26,29 @@
|
|||||||
<:tab title="Media" id="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:gap-6 mt-6">
|
||||||
<div>
|
<div>
|
||||||
<.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">
|
||||||
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
|
<h2 class="text-xl mb-2">{@media_item.title}</h2>
|
||||||
|
<div>Uploaded: {DateTime.to_date(@media_item.uploaded_at)}</div>
|
||||||
<div>
|
<div>
|
||||||
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
<span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
||||||
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
|
||||||
<span class="mx-2">or</span>
|
<span class="mx-2">or</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<.subtle_link href={~p"/media/#{@media_item.uuid}/stream"} target="_blank">
|
<.subtle_link
|
||||||
|
href={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
Open Local Stream
|
Open Local Stream
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
||||||
<.break_on_newline text={@media_item.description} />
|
<.render_description text={@media_item.description} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -54,7 +58,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<strong>Source:</strong>
|
<strong>Source:</strong>
|
||||||
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||||
<%= @media_item.source.custom_name %>
|
{@media_item.source.custom_name}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||||
</section>
|
</section>
|
||||||
@@ -64,13 +68,13 @@
|
|||||||
<%= 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">
|
||||||
<%= task.job.worker %>
|
{task.job.worker}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="State">
|
<:col :let={task} label="State">
|
||||||
<%= task.job.state %>
|
{task.job.state}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Scheduled At">
|
<:col :let={task} label="Scheduled At">
|
||||||
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
|
<.datetime_in_zone datetime={task.job.scheduled_at} />
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -1,24 +1,51 @@
|
|||||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
use Pinchflat.Sources.SourcesQuery
|
use Pinchflat.Sources.SourcesQuery
|
||||||
|
use Pinchflat.Profiles.ProfilesQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
|
alias Pinchflat.Profiles.MediaProfileDeletionWorker
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
media_profiles =
|
media_profiles_query =
|
||||||
MediaProfile
|
from mp in MediaProfile,
|
||||||
|> order_by(asc: :name)
|
as: :media_profile,
|
||||||
|> Repo.all()
|
where: is_nil(mp.marked_for_deletion_at),
|
||||||
|
order_by: [asc: mp.name],
|
||||||
|
select: map(mp, ^MediaProfile.__schema__(:fields)),
|
||||||
|
select_merge: %{
|
||||||
|
source_count:
|
||||||
|
subquery(
|
||||||
|
from s in Source,
|
||||||
|
where: s.media_profile_id == parent_as(:media_profile).id,
|
||||||
|
select: count(s.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
render(conn, :index, media_profiles: media_profiles)
|
render(conn, :index, media_profiles: Repo.all(media_profiles_query))
|
||||||
end
|
end
|
||||||
|
|
||||||
def new(conn, _params) do
|
def new(conn, params) do
|
||||||
changeset = Profiles.change_media_profile(%MediaProfile{})
|
# Preload an existing media profile for faster creation
|
||||||
|
cs_struct =
|
||||||
|
case to_string(params["template_id"]) do
|
||||||
|
"" -> %MediaProfile{}
|
||||||
|
template_id -> Repo.get(MediaProfile, template_id) || %MediaProfile{}
|
||||||
|
end
|
||||||
|
|
||||||
render(conn, :new, changeset: changeset, layout: get_onboarding_layout())
|
render(conn, :new,
|
||||||
|
layout: get_onboarding_layout(),
|
||||||
|
changeset:
|
||||||
|
Profiles.change_media_profile(%MediaProfile{
|
||||||
|
cs_struct
|
||||||
|
| id: nil,
|
||||||
|
name: nil,
|
||||||
|
marked_for_deletion_at: nil
|
||||||
|
})
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create(conn, %{"media_profile" => media_profile_params}) do
|
def create(conn, %{"media_profile" => media_profile_params}) do
|
||||||
@@ -70,19 +97,15 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
delete_files = Map.get(params, "delete_files", false)
|
# This awkward comparison converts the string to a boolean
|
||||||
|
delete_files = Map.get(params, "delete_files", "") == "true"
|
||||||
media_profile = Profiles.get_media_profile!(id)
|
media_profile = Profiles.get_media_profile!(id)
|
||||||
{:ok, _media_profile} = Profiles.delete_media_profile(media_profile, delete_files: delete_files)
|
|
||||||
|
|
||||||
flash_message =
|
{:ok, _} = Profiles.update_media_profile(media_profile, %{marked_for_deletion_at: DateTime.utc_now()})
|
||||||
if delete_files do
|
MediaProfileDeletionWorker.kickoff(media_profile, %{delete_files: delete_files})
|
||||||
"Media profile, its sources, and its files deleted successfully."
|
|
||||||
else
|
|
||||||
"Media profile and its sources deleted successfully. Files were not deleted."
|
|
||||||
end
|
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, "Media Profile deletion started. This may take a while to complete.")
|
||||||
|> redirect(to: ~p"/media_profiles")
|
|> redirect(to: ~p"/media_profiles")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
"""
|
"""
|
||||||
attr :changeset, Ecto.Changeset, required: true
|
attr :changeset, Ecto.Changeset, required: true
|
||||||
attr :action, :string, required: true
|
attr :action, :string, required: true
|
||||||
|
attr :method, :string, required: true
|
||||||
|
|
||||||
def media_profile_form(assigns)
|
def media_profile_form(assigns)
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
|||||||
[
|
[
|
||||||
{"8k", "4320p"},
|
{"8k", "4320p"},
|
||||||
{"4k", "2160p"},
|
{"4k", "2160p"},
|
||||||
|
{"1440p", "1440p"},
|
||||||
{"1080p", "1080p"},
|
{"1080p", "1080p"},
|
||||||
{"720p", "720p"},
|
{"720p", "720p"},
|
||||||
{"480p", "480p"},
|
{"480p", "480p"},
|
||||||
|
|||||||
+6
-1
@@ -2,7 +2,7 @@
|
|||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
|
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@media_profile))}`,
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
</span>
|
</span>
|
||||||
</:option>
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link href={~p"/media_profiles/new?template_id=#{@media_profile}"} method="get">
|
||||||
|
Use as Template
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
<:option>
|
<:option>
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
</:option>
|
</:option>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
Editing "<%= @media_profile.name %>"
|
Editing "{@media_profile.name}"
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<div class="flex flex-col gap-10">
|
<div class="flex flex-col gap-10">
|
||||||
<.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} />
|
<.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} method="patch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,18 +10,22 @@
|
|||||||
</.link>
|
</.link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
|
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<div class="flex flex-col gap-10 min-w-max">
|
<div class="flex flex-col gap-10 min-w-max">
|
||||||
<.table rows={@media_profiles} table_class="text-black dark:text-white">
|
<.table rows={@media_profiles} table_class="text-black dark:text-white">
|
||||||
<:col :let={media_profile} label="Name">
|
<:col :let={media_profile} label="Name">
|
||||||
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
|
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
|
||||||
<%= media_profile.name %>
|
{media_profile.name}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_profile} label="Preferred Resolution">
|
<:col :let={media_profile} label="Preferred Resolution">
|
||||||
<%= media_profile.preferred_resolution %>
|
{media_profile.preferred_resolution}
|
||||||
|
</:col>
|
||||||
|
<:col :let={media_profile} label="Sources">
|
||||||
|
<.subtle_link href={~p"/media_profiles/#{media_profile.id}/#tab-sources"}>
|
||||||
|
<.localized_number number={media_profile.source_count} />
|
||||||
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_profile} label="" class="flex justify-end">
|
<:col :let={media_profile} label="" class="flex justify-end">
|
||||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||||
|
|||||||
+36
-5
@@ -1,4 +1,11 @@
|
|||||||
<.simple_form :let={f} for={@changeset} action={@action}>
|
<.simple_form
|
||||||
|
:let={f}
|
||||||
|
for={@changeset}
|
||||||
|
action={@action}
|
||||||
|
method={@method}
|
||||||
|
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>
|
||||||
@@ -30,10 +37,14 @@
|
|||||||
</.input>
|
</.input>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
<section class="flex justify-between items-center mt-8">
|
||||||
General Options
|
<h3 class="text-2xl text-white">
|
||||||
</h3>
|
General Options
|
||||||
|
</h3>
|
||||||
|
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||||
|
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
<section x-data="{
|
<section x-data="{
|
||||||
presets: {
|
presets: {
|
||||||
default: 'Default',
|
default: 'Default',
|
||||||
@@ -114,6 +125,16 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section x-show="advancedMode">
|
||||||
|
<.input
|
||||||
|
field={f[:audio_track]}
|
||||||
|
placeholder="de"
|
||||||
|
type="text"
|
||||||
|
label="Audio Track Language"
|
||||||
|
help="Only works if there are multiple audio tracks. Use either a language code, 'original' for the original audio track, or 'default' for YouTube's preference. Or just leave it blank"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3 class="mt-10 text-2xl text-black dark:text-white">
|
<h3 class="mt-10 text-2xl text-black dark:text-white">
|
||||||
Thumbnail Options
|
Thumbnail Options
|
||||||
</h3>
|
</h3>
|
||||||
@@ -203,6 +224,16 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section x-show="advancedMode">
|
||||||
|
<.input
|
||||||
|
field={f[:media_container]}
|
||||||
|
type="text"
|
||||||
|
label="Media Container"
|
||||||
|
placeholder="mp4"
|
||||||
|
help="Don't change this if you're going to consume media via Plex. Leave blank for default"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section x-data="{ presets: { default: null, media_center: 1, audio: null, archiving: 1 } }">
|
<section x-data="{ presets: { default: null, media_center: 1, audio: null, archiving: 1 } }">
|
||||||
<.input
|
<.input
|
||||||
field={f[:redownload_delay_days]}
|
field={f[:redownload_delay_days]}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<div class="flex flex-col gap-10">
|
<div class="flex flex-col gap-10">
|
||||||
<.media_profile_form changeset={@changeset} action={~p"/media_profiles"} />
|
<.media_profile_form changeset={@changeset} action={~p"/media_profiles"} method="post" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-10
@@ -6,7 +6,9 @@
|
|||||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
<li>
|
<li>
|
||||||
Liquid-style:
|
Liquid-style:
|
||||||
<.inline_code>/{{ channel }}/{{ title }} - {{ id }}.{{ ext }}</.inline_code>
|
<.inline_code>
|
||||||
|
{"/#{double_brace("channel")}/#{double_brace("title")} - #{double_brace("id")}.#{double_brace("ext")}"}
|
||||||
|
</.inline_code>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code class="text-sm">yt-dlp</code>-style
|
<code class="text-sm">yt-dlp</code>-style
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Any bare words:
|
Any bare words:
|
||||||
<.inline_code>/videos/1080p/{{ id }}.{{ ext }}</.inline_code>
|
<.inline_code>{"/videos/1080p/#{double_brace("id")}.#{double_brace("ext")}"}</.inline_code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="my-2">
|
<p class="my-2">
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
<em>any</em>
|
<em>any</em>
|
||||||
single-word <code class="text-sm">yt-dlp</code>
|
single-word <code class="text-sm">yt-dlp</code>
|
||||||
option can be used as liquid-style and it's automatically made filepath-safe. For example, the
|
option can be used as liquid-style and it's automatically made filepath-safe. For example, the
|
||||||
<.inline_code>{{ duration }}</.inline_code>
|
<.inline_code>{double_brace("duration")}</.inline_code>
|
||||||
option is translated to
|
option is translated to
|
||||||
<.inline_code>%(duration)S</.inline_code>
|
<.inline_code>%(duration)S</.inline_code>
|
||||||
</p>
|
</p>
|
||||||
@@ -33,12 +35,14 @@
|
|||||||
<strong>Major 🔑:</strong>
|
<strong>Major 🔑:</strong>
|
||||||
these syntaxes can be mixed and matched freely! I prefer to use liquid-style and bare words
|
these syntaxes can be mixed and matched freely! I prefer to use liquid-style and bare words
|
||||||
but I'll include <code class="text-sm">yt-dlp</code>-style when I need more control. For example:
|
but I'll include <code class="text-sm">yt-dlp</code>-style when I need more control. For example:
|
||||||
<.inline_code>/1080p/{{ channel }}/{{ title }}-(%(subtitles.en.-1.ext)s).{{ ext }}</.inline_code>
|
<.inline_code>
|
||||||
|
{"/1080p/#{double_brace("channel")}/#{double_brace("title")}-(%(subtitles.en.-1.ext)s).#{double_brace("ext")}"}
|
||||||
|
</.inline_code>
|
||||||
</p>
|
</p>
|
||||||
<p class="my-2">
|
<p class="my-2">
|
||||||
<strong>NOTE:</strong>
|
<strong>NOTE:</strong>
|
||||||
Your template <em>must</em>
|
Your template <em>must</em>
|
||||||
end with an extension option (<.inline_code>.{{ ext }}</.inline_code>
|
end with an extension option (<.inline_code>.{double_brace("ext")}</.inline_code>
|
||||||
or
|
or
|
||||||
<.inline_code>.%(ext)S</.inline_code>).
|
<.inline_code>.%(ext)S</.inline_code>).
|
||||||
Downloading won't work as expected without it.
|
Downloading won't work as expected without it.
|
||||||
@@ -59,21 +63,21 @@
|
|||||||
<h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3>
|
<h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3>
|
||||||
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
||||||
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
|
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
|
||||||
<.inline_code>{{ <%= k %> }}</.inline_code>
|
<.inline_code>{double_brace(k)}</.inline_code>
|
||||||
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
<span :if={v}>- {html_escape({:safe, v})}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3>
|
<h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3>
|
||||||
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
||||||
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
|
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
|
||||||
<.inline_code>{{ <%= k %> }}</.inline_code>
|
<.inline_code>{double_brace(k)}</.inline_code>
|
||||||
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
<span :if={v}>- {html_escape({:safe, v})}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3 class="text-lg font-bold mb-2">Common Options</h3>
|
<h3 class="text-lg font-bold mb-2">Common Options</h3>
|
||||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||||
<li :for={opt <- common_output_template_options()} class="mt-1">
|
<li :for={opt <- common_output_template_options()} class="mt-1">
|
||||||
<.inline_code>{{ <%= opt %> }}</.inline_code>
|
<.inline_code>{double_brace(opt)}</.inline_code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
|
||||||
<%= @media_profile.name %>
|
{@media_profile.name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@
|
|||||||
<.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 %>
|
{source.custom_name || source.collection_name}
|
||||||
</.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="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>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ defmodule PinchflatWeb.Pages.PageHTML do
|
|||||||
})
|
})
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<.localized_number number={@num} /> <%= @suffix %>
|
<.localized_number number={@num} /> {@suffix}
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,21 +30,21 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||||||
<.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/#{media_item.source_id}/media/#{media_item}"}>
|
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
|
||||||
<%= StringUtils.truncate(media_item.title, 35) %>
|
{StringUtils.truncate(media_item.title, 35)}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Upload Date">
|
<:col :let={media_item} label="Upload Date">
|
||||||
<%= DateTime.to_date(media_item.uploaded_at) %>
|
{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)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Downloaded At">
|
<:col :let={media_item} label="Downloaded At">
|
||||||
<%= format_datetime(media_item.media_downloaded_at) %>
|
{format_datetime(media_item.media_downloaded_at)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Source">
|
<:col :let={media_item} label="Source">
|
||||||
<.subtle_link href={~p"/sources/#{media_item.source_id}"}>
|
<.subtle_link href={~p"/sources/#{media_item.source_id}"}>
|
||||||
<%= StringUtils.truncate(media_item.source.custom_name, 35) %>
|
{StringUtils.truncate(media_item.source.custom_name, 35)}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
|
|||||||
@@ -42,13 +42,13 @@
|
|||||||
<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">Media 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">
|
<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>
|
<span class="text-2xl font-medium mb-4">Active Tasks</span>
|
||||||
<section class="mt-6 min-h-80">
|
<section class="mt-6 min-h-80">
|
||||||
<%= live_render(@conn, Pinchflat.Pages.JobTableLive) %>
|
{live_render(@conn, Pinchflat.Pages.JobTableLive)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,18 +19,18 @@ defmodule Pinchflat.Pages.JobTableLive do
|
|||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.table rows={@tasks} table_class="text-white">
|
<.table rows={@tasks} table_class="text-white">
|
||||||
<:col :let={task} label="Task">
|
<:col :let={task} label="Task">
|
||||||
<%= worker_to_task_name(task.job.worker) %>
|
{worker_to_task_name(task.job.worker)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Subject">
|
<:col :let={task} label="Subject">
|
||||||
<.subtle_link href={task_to_link(task)}>
|
<.subtle_link href={task_to_link(task)}>
|
||||||
<%= StringUtils.truncate(task_to_record_name(task), 35) %>
|
{StringUtils.truncate(task_to_record_name(task), 35)}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Attempt No.">
|
<:col :let={task} label="Attempt No.">
|
||||||
<%= task.job.attempt %>
|
{task.job.attempt}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Started At">
|
<:col :let={task} label="Started At">
|
||||||
<%= format_datetime(task.job.attempted_at) %>
|
{format_datetime(task.job.attempted_at)}
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.Searches.SearchHTML do
|
|||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%= for fragment <- @split_string do %>
|
<%= for fragment <- @split_string do %>
|
||||||
<%= render_fragment(fragment) %>
|
{render_fragment(fragment)}
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white">
|
||||||
Results for "<%= StringUtils.truncate(@search_term, 50) %>"
|
Results for "{StringUtils.truncate(@search_term, 50)}"
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<.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">
|
||||||
<.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
|
<.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
|
||||||
<%= StringUtils.truncate(result.title, 35) %>
|
{StringUtils.truncate(result.title, 35)}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={result} label="Excerpt">
|
<:col :let={result} label="Excerpt">
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<%= live_render(
|
{live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Settings.AppriseServerLive,
|
Pinchflat.Settings.AppriseServerLive,
|
||||||
session: %{"value" => f[:apprise_server].value}
|
session: %{"value" => f[:apprise_server].value}
|
||||||
) %>
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
|
|||||||
@@ -1,44 +1,20 @@
|
|||||||
defmodule PinchflatWeb.Sources.SourceController do
|
defmodule PinchflatWeb.Sources.SourceController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
use Pinchflat.Media.MediaQuery
|
use Pinchflat.Sources.SourcesQuery
|
||||||
|
|
||||||
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.Media.FileSyncingWorker
|
||||||
|
alias Pinchflat.Sources.SourceDeletionWorker
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
source_query =
|
render(conn, :index)
|
||||||
from s in Source,
|
|
||||||
as: :source,
|
|
||||||
inner_join: mp in assoc(s, :media_profile),
|
|
||||||
preload: [media_profile: mp],
|
|
||||||
order_by: [asc: s.custom_name],
|
|
||||||
select: map(s, ^Source.__schema__(:fields)),
|
|
||||||
select_merge: %{
|
|
||||||
downloaded_count:
|
|
||||||
subquery(
|
|
||||||
from m in MediaItem,
|
|
||||||
where: m.source_id == parent_as(:source).id,
|
|
||||||
where: ^MediaQuery.downloaded(),
|
|
||||||
select: count(m.id)
|
|
||||||
),
|
|
||||||
pending_count:
|
|
||||||
subquery(
|
|
||||||
from m in MediaItem,
|
|
||||||
join: s in assoc(m, :source),
|
|
||||||
where: m.source_id == parent_as(:source).id,
|
|
||||||
where: ^MediaQuery.pending(),
|
|
||||||
select: count(m.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
render(conn, :index, sources: Repo.all(source_query))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new(conn, params) do
|
def new(conn, params) do
|
||||||
@@ -60,10 +36,12 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
| id: nil,
|
| id: nil,
|
||||||
uuid: nil,
|
uuid: nil,
|
||||||
custom_name: nil,
|
custom_name: nil,
|
||||||
|
description: nil,
|
||||||
collection_name: nil,
|
collection_name: nil,
|
||||||
collection_id: nil,
|
collection_id: nil,
|
||||||
collection_type: nil,
|
collection_type: nil,
|
||||||
original_url: nil
|
original_url: nil,
|
||||||
|
marked_for_deletion_at: nil
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -124,19 +102,15 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id} = params) do
|
def delete(conn, %{"id" => id} = params) do
|
||||||
delete_files = Map.get(params, "delete_files", false)
|
# This awkward comparison converts the string to a boolean
|
||||||
|
delete_files = Map.get(params, "delete_files", "") == "true"
|
||||||
source = Sources.get_source!(id)
|
source = Sources.get_source!(id)
|
||||||
{:ok, _source} = Sources.delete_source(source, delete_files: delete_files)
|
|
||||||
|
|
||||||
flash_message =
|
{:ok, _} = Sources.update_source(source, %{marked_for_deletion_at: DateTime.utc_now()})
|
||||||
if delete_files do
|
SourceDeletionWorker.kickoff(source, %{delete_files: delete_files})
|
||||||
"Source and files deleted successfully."
|
|
||||||
else
|
|
||||||
"Source deleted successfully. Files were not deleted."
|
|
||||||
end
|
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, "Source deletion started. This may take a while to complete.")
|
||||||
|> redirect(to: ~p"/sources")
|
|> redirect(to: ~p"/sources")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -176,6 +150,15 @@ defmodule PinchflatWeb.Sources.SourceController do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_files_on_disk(conn, %{"source_id" => id}) do
|
||||||
|
wrap_forced_action(
|
||||||
|
conn,
|
||||||
|
id,
|
||||||
|
"File sync enqueued.",
|
||||||
|
&FileSyncingWorker.kickoff_with_task/1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp wrap_forced_action(conn, source_id, message, fun) do
|
defp wrap_forced_action(conn, source_id, message, fun) do
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
fun.(source)
|
fun.(source)
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cutoff_date_presets do
|
||||||
|
[
|
||||||
|
{"7 days", compute_date_offset(7)},
|
||||||
|
{"14 days", compute_date_offset(14)},
|
||||||
|
{"30 days", compute_date_offset(30)},
|
||||||
|
{"60 days", compute_date_offset(60)},
|
||||||
|
{"90 days", compute_date_offset(90)},
|
||||||
|
{"180 days", compute_date_offset(180)},
|
||||||
|
{"365 days", compute_date_offset(365)}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
def rss_feed_url(conn, source) do
|
def rss_feed_url(conn, source) do
|
||||||
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
||||||
end
|
end
|
||||||
@@ -55,4 +67,13 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
|||||||
Must end with .{{ ext }}. Same rules as Media Profile output path templates. #{help_button} to load your media profile's output template
|
Must end with .{{ ext }}. Same rules as Media Profile output path templates. #{help_button} to load your media profile's output template
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp compute_date_offset(days) do
|
||||||
|
timezone = Application.get_env(:pinchflat, :timezone)
|
||||||
|
|
||||||
|
timezone
|
||||||
|
|> Timex.now()
|
||||||
|
|> Timex.shift(days: -days)
|
||||||
|
|> Timex.format!("{YYYY}-{0M}-{0D}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<:option>
|
<:option>
|
||||||
<span x-data="{ copied: false }" x-on:click={~s"
|
<span x-data="{ copied: false }" x-on:click={~s"
|
||||||
copyWithCallbacks(
|
copyWithCallbacks(
|
||||||
`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
|
String.raw`#{Jason.Formatter.pretty_print(Phoenix.json_library().encode!(@source))}`,
|
||||||
() => copied = true,
|
() => copied = true,
|
||||||
() => copied = false
|
() => copied = false
|
||||||
)
|
)
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<.link
|
<.link
|
||||||
href={~p"/sources/#{@source}/force_download_pending"}
|
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."
|
||||||
>
|
>
|
||||||
Download Pending
|
Download Pending
|
||||||
</.link>
|
</.link>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<.link
|
<.link
|
||||||
href={~p"/sources/#{@source}/force_redownload"}
|
href={~p"/sources/#{@source}/force_redownload"}
|
||||||
method="post"
|
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."
|
data-confirm="Are you sure you want to re-download all currently downloaded media items? This doesn't upgrade your media, but will download any missing files if your settings have changed. This isn't normally needed."
|
||||||
>
|
>
|
||||||
Redownload Existing
|
Redownload Existing
|
||||||
</.link>
|
</.link>
|
||||||
@@ -70,6 +70,15 @@
|
|||||||
Refresh Metadata
|
Refresh Metadata
|
||||||
</.link>
|
</.link>
|
||||||
</:option>
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}/sync_files_on_disk"}
|
||||||
|
method="post"
|
||||||
|
data-confirm="Are you sure you want to sync files? This will update media items if their files have been deleted. File addition or moves are not detected. This isn't normally needed."
|
||||||
|
>
|
||||||
|
Sync Files on Disk
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
<:option>
|
<:option>
|
||||||
<div class="h-px w-full bg-bodydark2"></div>
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
</:option>
|
</:option>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
Editing "<%= @source.custom_name %>"
|
Editing "{@source.custom_name}"
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,32 +12,7 @@
|
|||||||
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
|
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<div class="flex flex-col gap-10 min-w-max">
|
<div class="flex flex-col gap-10 min-w-max">
|
||||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
{live_render(@conn, PinchflatWeb.Sources.IndexTableLive)}
|
||||||
<:col :let={source} label="Name">
|
|
||||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
|
||||||
<%= StringUtils.truncate(source.custom_name || source.collection_name, 35) %>
|
|
||||||
</.subtle_link>
|
|
||||||
</:col>
|
|
||||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
|
||||||
<:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
|
|
||||||
<:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
|
|
||||||
<:col :let={source} label="Retention">
|
|
||||||
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
|
||||||
<.localized_number number={source.retention_period_days} />
|
|
||||||
<.pluralize count={source.retention_period_days} word="day" />
|
|
||||||
<% else %>
|
|
||||||
<span class="text-lg">∞</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
|
||||||
<:col :let={source} label="Media Profile">
|
|
||||||
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
|
||||||
<%= source.media_profile.name %>
|
|
||||||
</.subtle_link>
|
|
||||||
</:col>
|
|
||||||
<:col :let={source} label="" class="flex place-content-evenly">
|
|
||||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
|
||||||
</:col>
|
|
||||||
</.table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
defmodule PinchflatWeb.Sources.IndexTableLive do
|
||||||
|
use PinchflatWeb, :live_view
|
||||||
|
use Pinchflat.Media.MediaQuery
|
||||||
|
use Pinchflat.Sources.SourcesQuery
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Media.MediaItem
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<.table rows={@sources} table_class="text-white">
|
||||||
|
<:col :let={source} label="Name">
|
||||||
|
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||||
|
{StringUtils.truncate(source.custom_name || source.collection_name, 35)}
|
||||||
|
</.subtle_link>
|
||||||
|
</:col>
|
||||||
|
<:col :let={source} label="Pending">
|
||||||
|
<.subtle_link href={~p"/sources/#{source.id}/#tab-pending"}>
|
||||||
|
<.localized_number number={source.pending_count} />
|
||||||
|
</.subtle_link>
|
||||||
|
</:col>
|
||||||
|
<:col :let={source} label="Downloaded">
|
||||||
|
<.subtle_link href={~p"/sources/#{source.id}/#tab-downloaded"}>
|
||||||
|
<.localized_number number={source.downloaded_count} />
|
||||||
|
</.subtle_link>
|
||||||
|
</:col>
|
||||||
|
<:col :let={source} label="Retention">
|
||||||
|
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
|
||||||
|
<.localized_number number={source.retention_period_days} />
|
||||||
|
<.pluralize count={source.retention_period_days} word="day" />
|
||||||
|
<% else %>
|
||||||
|
<span class="text-lg">∞</span>
|
||||||
|
<% end %>
|
||||||
|
</:col>
|
||||||
|
<:col :let={source} label="Media Profile">
|
||||||
|
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
||||||
|
{source.media_profile.name}
|
||||||
|
</.subtle_link>
|
||||||
|
</:col>
|
||||||
|
<:col :let={source} label="Enabled?">
|
||||||
|
<.input
|
||||||
|
name={"source[#{source.id}][enabled]"}
|
||||||
|
value={source.enabled}
|
||||||
|
id={"source_#{source.id}_enabled"}
|
||||||
|
phx-hook="formless-input"
|
||||||
|
data-subscribe="change"
|
||||||
|
data-event-name="toggle_enabled"
|
||||||
|
data-identifier={source.id}
|
||||||
|
type="toggle"
|
||||||
|
/>
|
||||||
|
</:col>
|
||||||
|
<:col :let={source} label="" class="flex place-content-evenly">
|
||||||
|
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||||
|
</:col>
|
||||||
|
</.table>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, assign(socket, %{sources: get_sources()})}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("formless-input", %{"event" => "toggle_enabled"} = params, socket) do
|
||||||
|
source = Sources.get_source!(params["id"])
|
||||||
|
should_enable = params["value"] == "true"
|
||||||
|
|
||||||
|
{:ok, _} = Sources.update_source(source, %{enabled: should_enable})
|
||||||
|
|
||||||
|
{:noreply, assign(socket, %{sources: get_sources()})}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_sources do
|
||||||
|
query =
|
||||||
|
from s in Source,
|
||||||
|
as: :source,
|
||||||
|
inner_join: mp in assoc(s, :media_profile),
|
||||||
|
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
|
||||||
|
preload: [media_profile: mp],
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repo.all(query)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
defmodule Pinchflat.Sources.MediaItemTableLive do
|
defmodule PinchflatWeb.Sources.MediaItemTableLive do
|
||||||
use PinchflatWeb, :live_view
|
use PinchflatWeb, :live_view
|
||||||
use Pinchflat.Media.MediaQuery
|
use Pinchflat.Media.MediaQuery
|
||||||
|
|
||||||
@@ -48,14 +48,14 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
|||||||
<.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} :if={@media_state == "other"} label="Manually Ignored?">
|
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
|
||||||
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
|
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="Upload Date">
|
<:col :let={media_item} label="Upload Date">
|
||||||
<%= DateTime.to_date(media_item.uploaded_at) %>
|
{DateTime.to_date(media_item.uploaded_at)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_item} label="" class="flex justify-end">
|
<:col :let={media_item} label="" class="flex justify-end">
|
||||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
||||||
<%= @source.custom_name %>
|
{@source.custom_name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<strong>Media Profile:</strong>
|
<strong>Media Profile:</strong>
|
||||||
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||||
<%= @source.media_profile.name %>
|
{@source.media_profile.name}
|
||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -37,37 +37,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Pending" id="pending">
|
<:tab title="Pending" id="pending">
|
||||||
<%= live_render(
|
{live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Sources.MediaItemTableLive,
|
PinchflatWeb.Sources.MediaItemTableLive,
|
||||||
session: %{"source_id" => @source.id, "media_state" => "pending"}
|
session: %{"source_id" => @source.id, "media_state" => "pending"}
|
||||||
) %>
|
)}
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Downloaded" id="downloaded">
|
<:tab title="Downloaded" id="downloaded">
|
||||||
<%= live_render(
|
{live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Sources.MediaItemTableLive,
|
PinchflatWeb.Sources.MediaItemTableLive,
|
||||||
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
|
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
|
||||||
) %>
|
)}
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Other" id="other">
|
<:tab title="Other" id="other">
|
||||||
<%= live_render(
|
{live_render(
|
||||||
@conn,
|
@conn,
|
||||||
Pinchflat.Sources.MediaItemTableLive,
|
PinchflatWeb.Sources.MediaItemTableLive,
|
||||||
session: %{"source_id" => @source.id, "media_state" => "other"}
|
session: %{"source_id" => @source.id, "media_state" => "other"}
|
||||||
) %>
|
)}
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Tasks" id="tasks">
|
<: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">
|
||||||
<%= task.job.worker %>
|
{task.job.worker}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="State">
|
<:col :let={task} label="State">
|
||||||
<%= task.job.state %>
|
{task.job.state}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={task} label="Scheduled At">
|
<:col :let={task} label="Scheduled At">
|
||||||
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
|
<.datetime_in_zone datetime={task.job.scheduled_at} />
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<section x-data="{ mediaProfileId: null }">
|
<section x-data="{ mediaProfileId: null }">
|
||||||
<section class="flex justify-between items-center mt-4">
|
<section class="flex justify-between items-center mt-4">
|
||||||
<h3 class=" text-2xl text-black dark:text-white">
|
<h3 class="text-2xl text-white">
|
||||||
General Options
|
General Options
|
||||||
</h3>
|
</h3>
|
||||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||||
@@ -87,16 +87,56 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:download_cutoff_date]}
|
field={f[:use_cookies]}
|
||||||
type="text"
|
type="toggle"
|
||||||
label="Download Cutoff Date"
|
label="Use Cookies for Downloading"
|
||||||
placeholder="YYYY-MM-DD"
|
help="Uses your YouTube cookies for this source (if configured). Used for downloading private playlists and videos. See docs for important details"
|
||||||
maxlength="10"
|
|
||||||
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
|
|
||||||
title="YYYY-MM-DD"
|
|
||||||
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section x-show="advancedMode">
|
||||||
|
<.input
|
||||||
|
field={f[:min_duration_seconds]}
|
||||||
|
type="number"
|
||||||
|
label="Minimum Duration (seconds)"
|
||||||
|
min="0"
|
||||||
|
help="Minimum duration of the media to be downloaded. Can be blank"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={f[:max_duration_seconds]}
|
||||||
|
type="number"
|
||||||
|
label="Maximum Duration (seconds)"
|
||||||
|
min="0"
|
||||||
|
help="Maximum duration of the media to be downloaded. Can be blank"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section x-data={"{ cutoffDate: '#{f[:download_cutoff_date].value}' }"}>
|
||||||
|
<.input
|
||||||
|
field={f[:download_cutoff_date]}
|
||||||
|
type="text"
|
||||||
|
label="Download Cutoff Date"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
maxlength="10"
|
||||||
|
pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])"
|
||||||
|
title="YYYY-MM-DD"
|
||||||
|
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format. Old media may be deleted or downloaded if you change this date"
|
||||||
|
x-model="cutoffDate"
|
||||||
|
>
|
||||||
|
<:input_append>
|
||||||
|
<.input
|
||||||
|
prompt="Select preset"
|
||||||
|
name="download_cutoff_date_preset"
|
||||||
|
value=""
|
||||||
|
options={cutoff_date_presets()}
|
||||||
|
type="select"
|
||||||
|
inputclass="w-30 lg:w-60 ml-2 md:ml-4"
|
||||||
|
x-on:change={"cutoffDate = $event.target.value || '#{f[:download_cutoff_date].value}'"}
|
||||||
|
/>
|
||||||
|
</:input_append>
|
||||||
|
</.input>
|
||||||
|
</section>
|
||||||
|
|
||||||
<.input
|
<.input
|
||||||
field={f[:retention_period_days]}
|
field={f[:retention_period_days]}
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ defmodule PinchflatWeb.Router do
|
|||||||
post "/force_redownload", Sources.SourceController, :force_redownload
|
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
|
||||||
|
post "/sync_files_on_disk", Sources.SourceController, :sync_files_on_disk
|
||||||
|
|
||||||
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
|
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete] do
|
||||||
post "/force_download", MediaItems.MediaItemController, :force_download
|
post "/force_download", MediaItems.MediaItemController, :force_download
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "2024.7.19-1",
|
version: "2024.12.10",
|
||||||
elixir: "~> 1.17",
|
elixir: "~> 1.17",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
@@ -46,16 +46,16 @@ defmodule Pinchflat.MixProject do
|
|||||||
# Type `mix help deps` for examples and options.
|
# Type `mix help deps` for examples and options.
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:phoenix, "~> 1.7.10"},
|
{:phoenix, "~> 1.7.14"},
|
||||||
{:phoenix_ecto, "~> 4.4"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:ecto, "~> 3.11.2"},
|
{:ecto, "~> 3.12.3"},
|
||||||
{:ecto_sql, "~> 3.10"},
|
{:ecto_sql, "~> 3.12"},
|
||||||
{:ecto_sqlite3, ">= 0.0.0"},
|
{:ecto_sqlite3, ">= 0.0.0"},
|
||||||
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
||||||
{:phoenix_html, "~> 3.3"},
|
{:phoenix_html, "~> 3.3"},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
{:phoenix_live_view, "~> 0.20.1"},
|
{:phoenix_live_view, "~> 1.0.0"},
|
||||||
{:floki, ">= 0.30.0", only: :test},
|
{:floki, ">= 0.36.0", only: :test},
|
||||||
{:phoenix_live_dashboard, "~> 0.8.2"},
|
{:phoenix_live_dashboard, "~> 0.8.2"},
|
||||||
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
|
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
|
||||||
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
|
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
|
||||||
@@ -67,7 +67,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
{:jason, "~> 1.2"},
|
{:jason, "~> 1.2"},
|
||||||
{:dns_cluster, "~> 0.1.1"},
|
{:dns_cluster, "~> 0.1.1"},
|
||||||
{:plug_cowboy, "~> 2.5"},
|
{:plug_cowboy, "~> 2.5"},
|
||||||
{:oban, "~> 2.16"},
|
{:oban, "~> 2.17"},
|
||||||
{:nimble_parsec, "~> 1.4"},
|
{:nimble_parsec, "~> 1.4"},
|
||||||
{:timex, "~> 3.0"},
|
{:timex, "~> 3.0"},
|
||||||
{:mox, "~> 1.0", only: :test},
|
{:mox, "~> 1.0", only: :test},
|
||||||
@@ -103,7 +103,8 @@ defmodule Pinchflat.MixProject do
|
|||||||
"ecto.rollback": [
|
"ecto.rollback": [
|
||||||
"ecto.rollback",
|
"ecto.rollback",
|
||||||
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
||||||
]
|
],
|
||||||
|
"version.bump": "cmd ./tooling/version_bump.sh"
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,68 +1,68 @@
|
|||||||
%{
|
%{
|
||||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||||
"castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
|
"castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.9", "e8d3364f310da6ce6463c3dd20cf90ae7bbecbf6c5203b98bf9b48035592649b", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9dcab3d0f3038621f1601f13539e7a9ee99843862e66ad62827b0c42b2f58a54"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
|
||||||
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
|
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
|
||||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
||||||
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
|
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
|
||||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
|
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
|
||||||
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
|
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
|
||||||
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
|
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
|
||||||
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
|
||||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
"decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"},
|
||||||
"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.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": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [: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", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"},
|
||||||
"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.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 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", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
|
||||||
"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.17.2", "200226e057f76c40be55fbac77771eb1a233260ab8ec7283f5da6d9402bde8de", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "a3838919c5a34c268c28cafab87b910bcda354a9a4e778658da46c149bb2c1da"},
|
||||||
"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"},
|
||||||
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
|
"elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"},
|
||||||
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
||||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||||
"expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"},
|
"expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"},
|
||||||
"exqlite": {:hex, :exqlite, "0.19.0", "0f3ee29e35bed38552dd0ed59600aa81c78f867f5b5ff0e17d330148e0465483", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "55a8fbb0443f03d4a256e3458bd1203eff5037a6624b76460eaaa9080f462b06"},
|
"exqlite": {:hex, :exqlite, "0.23.0", "6e851c937a033299d0784994c66da24845415072adbc455a337e20087bce9033", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "404341cceec5e6466aaed160cf0b58be2019b60af82588c215e1224ebd3ec831"},
|
||||||
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
|
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
|
||||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||||
"finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"},
|
"finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"},
|
||||||
"floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"},
|
"floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"},
|
||||||
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
||||||
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
|
||||||
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||||
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
|
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
|
||||||
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
|
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
|
||||||
"mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"},
|
"mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"},
|
||||||
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
|
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
|
||||||
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
|
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
|
||||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
|
||||||
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
|
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
|
||||||
"oban": {:hex, :oban, "2.17.3", "ddfd5710aadcd550d2e174c8d73ce5f1865601418cf54a91775f20443fb832b7", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "452eada8bfe0d0fefd0740ab5fa8cf3ef6c375df0b4a3c3805d179022a04738a"},
|
"oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"},
|
||||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||||
"phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"},
|
"phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"},
|
||||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"},
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"},
|
||||||
"phoenix_html": {:hex, :phoenix_html, "3.3.3", "380b8fb45912b5638d2f1d925a3771b4516b9a78587249cabe394e0a5d579dc9", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "923ebe6fec6e2e3b3e569dfbdc6560de932cd54b000ada0208b5f45024bdd76c"},
|
"phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
|
||||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"},
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"},
|
||||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
|
||||||
"phoenix_live_view": {:hex, :phoenix_live_view, "0.20.3", "8b6406bc0a451f295407d7acff7f234a6314be5bbe0b3f90ed82b07f50049878", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8e4385e05618b424779f894ed2df97d3c7518b7285fcd11979077ae6226466b"},
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"},
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
|
||||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
"plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"},
|
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
|
||||||
"plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"},
|
"plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
|
||||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||||
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
|
||||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||||
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
|
||||||
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
|
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
|
||||||
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
|
||||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
|
||||||
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
|
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
|
||||||
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
|
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
|
||||||
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
|
"tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db",
|
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db",
|
||||||
"lint:check": "prettier . --check --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore",
|
"lint:check": "prettier . --check --config=.prettierrc.js --ignore-path=.prettierignore --ignore-path=.gitignore",
|
||||||
"lint:fix": "prettier . --write --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore"
|
"lint:fix": "prettier . --write --config=.prettierrc.js --ignore-path=.prettierignore --ignore-path=.gitignore"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 438 KiB |
+13
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddMarkedForDeletionAtToSources do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:sources) do
|
||||||
|
add :marked_for_deletion_at, :utc_datetime
|
||||||
|
end
|
||||||
|
|
||||||
|
alter table(:media_profiles) do
|
||||||
|
add :marked_for_deletion_at, :utc_datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule Pinchflat.Repo.Migrations.AddUseCookiesToSources do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:sources) do
|
||||||
|
add :use_cookies, :boolean, default: true, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user