Compare commits

..

21 Commits

Author SHA1 Message Date
Kieran Eglin 21eaeb8b60 Bumped version 2024-03-28 17:02:38 -07:00
Kieran Eglin 2b348607bf Combined old and new GH actions for docker 2024-03-28 16:40:50 -07:00
Kieran Eglin 2affcd8616 Testing conditionals 2024-03-28 16:32:54 -07:00
Kieran cab4c339e5 [Housekeeping] Add release workflow (takeover) (#147)
* [Housekeeping] Add release workflow (#141)

* add release workflow

This will create tags when a new release is published

* fix(workflows): refactor release workflow

Signed-off-by: Devin Buhl <devin@buhl.casa>

* Update release.yml

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>

* Linting

* Testing changes

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>
Co-authored-by: Devin Buhl <onedr0p@users.noreply.github.com>
2024-03-28 16:14:54 -07:00
Kieran 103541cd20 [Enhancement] Start tracking a media item's duration (#146)
* Added duration field, started importing it during indexing and download

* Added duration to RSS feed
2024-03-28 15:51:04 -07:00
Kieran Eglin 8cba501bff fixed typo 2024-03-28 15:11:12 -07:00
Kieran 6a02f217e3 [Triage] Allow changing SQLite journal mode (#145)
* Adds ability to configure journal mode for SQLite

* docs

* docs
2024-03-28 15:10:27 -07:00
Kieran c3f49184b5 [Feature] Add SponsorBlock-based section removal (#144)
* Added sponsorblock columns to media profile

* Added sponsorblock options to profile form

* Added SB to download options builder
2024-03-28 12:02:13 -07:00
Kieran 3837a0dedd trying something new with queries (#142) 2024-03-28 08:59:53 -07:00
Kieran Eglin c70f211232 Bumped version 2024-03-27 12:59:58 -07:00
Kieran ba0ded03a4 Updated fast indexing to respect title filters (#136) 2024-03-27 12:39:50 -07:00
Kieran Eglin 8513deef09 Hopefully fixed select styling 2024-03-27 12:28:25 -07:00
Kieran Eglin 83b9adea59 Turned GH actions inputs into dropdowns 2024-03-27 12:10:53 -07:00
Kieran Eglin 10a639a976 Fixed copy not working in insecure contexts 2024-03-27 12:07:15 -07:00
Kieran c79e27d9b3 Disabled index frequency when fast indexing is enabled (#135) 2024-03-27 11:36:35 -07:00
Kieran 8ef5e0f41f Added total downloaded count to source view (#134) 2024-03-27 10:27:48 -07:00
Kieran 854502634c RSS feed for sources (#110)
* Add media streaming (#108)

* [WIP] set up streaming route

* Added UUID to sources and media items

* Added media preview to MI show page

* Added plug to strip file extensions

* [VERY WIP] basic podcast RSS setup

* [WIP] got basic podcast RSS working

* [WIP] more expanding on RSS

* Comment

* [WIP] Working on refactoring feed

* Added UUID backfill to a migration

* [WIP] Moar refactoring

* [WIP] Adding UI for getting RSS feed

* Many tests

* Added conditional routing for feed URLs

* Removed the need for url_base to be set

* Updated preset name

* Rendered certain fields HTML-safe; Added logging to confirm range request support

* Fixed incorrect scheme issue

* Updated env var

* Updated other UI to use dropdown

* removed commented code

* Generate rss feeds (#123)

* Added plug to strip file extensions

* [VERY WIP] basic podcast RSS setup

* [WIP] got basic podcast RSS working

* [WIP] more expanding on RSS

* [WIP] Working on refactoring feed

* Added UUID backfill to a migration

* [WIP] Moar refactoring

* [WIP] Adding UI for getting RSS feed

* Many tests

* Added conditional routing for feed URLs

* Removed the need for url_base to be set

* Updated preset name

* Rendered certain fields HTML-safe; Added logging to confirm range request support

* Fixed incorrect scheme issue

* Updated env var

* Updated other UI to use dropdown

* removed commented code

* docs

* Added unique index to UUID fields
2024-03-27 10:11:25 -07:00
Kieran Eglin fc8eed8b50 Bumped version 2024-03-26 08:55:04 -07:00
Kieran f42aab286a [Bugfix] Expose port in Dockerfile and force IPv4 (#129)
* Exposes port on built image

* Forced IPv4

* Added current version to sidebar
2024-03-26 08:54:20 -07:00
Kieran bf11cd0dd9 Added description to source (#124) 2024-03-25 15:40:09 -07:00
Kieran Eglin e051a37f0d Pushed bug comment 2024-03-25 12:15:21 -07:00
65 changed files with 1829 additions and 391 deletions
@@ -1,46 +0,0 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker Image Tag'
required: true
default: 'dev'
platforms:
description: 'Build Platforms'
required: true
default: 'linux/amd64,linux/arm64'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
platforms: ${{ github.event.inputs.platforms }}
push: true
file: ./selfhosted.Dockerfile
tags: |
keglin/pinchflat:${{ github.event.inputs.image_tag }}
ghcr.io/${{ github.repository_owner }}/pinchflat:${{ github.event.inputs.image_tag }}
+79
View File
@@ -0,0 +1,79 @@
name: Docker Release
on:
workflow_dispatch:
inputs:
platforms:
type: choice
description: 'Build Platforms'
required: true
default: 'linux/amd64'
options:
- 'linux/amd64'
- 'linux/amd64,linux/arm64'
push:
branches:
- master
release:
types:
- published
jobs:
build_and_push_docker:
runs-on: ubuntu-latest
env:
dev_arch: 'linux/amd64'
release_arch: 'linux/amd64,linux/arm64'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
docker.io/keglin/pinchflat
ghcr.io/${{ github.repository }}
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
flavor: |
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
file: ./selfhosted.Dockerfile
# If the event is a release, use the release_arch, otherwise use the
# platforms input if present, falling back to dev_arch
platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+9 -1
View File
@@ -9,7 +9,15 @@
threshold: :low, threshold: :low,
# All of these are ignorable because this app is intended to be single-user and self-hosted. # All of these are ignorable because this app is intended to be single-user and self-hosted.
# There is an expectation that the user won't intentionally run a FS Traversal on themselves # There is an expectation that the user won't intentionally run a FS Traversal on themselves
ignore: ["CI.System", "Traversal.FileModule", "Config.HTTPS", "Config.CSP"], ignore:
[
"CI.System",
"Traversal.FileModule",
"Config.HTTPS",
"Config.CSP",
"XSS.ContentType",
"Traversal.SendFile"
],
ignore_files: [], ignore_files: [],
version: false version: false
] ]
+11 -28
View File
@@ -22,12 +22,12 @@
- [Installation](#installation) - [Installation](#installation)
- [Unraid](#unraid) - [Unraid](#unraid)
- [Docker](#docker) - [Docker](#docker)
- [Authentication](#authentication) - [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password)
- [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions) - [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions)
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki) - [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
- [EFF donations](#eff-donations) - [EFF donations](#eff-donations)
- [Pre-release disclaimer](#pre-release-disclaimer) - [Pre-release disclaimer](#pre-release-disclaimer)
- [Development](#development) - [Development and Contributing](https://github.com/kieraneglin/pinchflat/wiki/Development-and-Contributing)
## What it does ## What it does
@@ -43,6 +43,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- 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
- Supports serving RSS feeds to your favourite podcast app (beta - [docs](<https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds-(beta)>))
- 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
- Supports downloading audio content - Supports downloading audio content
@@ -64,6 +65,8 @@ Simply search for Pinchflat in the Community Apps store!
### Portainer ### Portainer
Important: See the note below about storing config on a network file share. It's preferred to store the config on a local disk if at all possible.
Docker Compose file: Docker Compose file:
```yaml ```yaml
@@ -103,9 +106,13 @@ You _must_ ensure the host directories you've mounted are writable by the user r
It's recommended to not run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media. 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. It's recommended to not run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media. 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.
## Username and Password ### Advanced: storing Pinchflat config directory on a network share
HTTP basic authentication is optionally supported. To use it, set the `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables when starting the container. No authentication will be required unless you set _both_ of these. README: This is currently in the testing phase and not a recommended option (yet). The implications of changing this setting isn't clear and this could, conceivably, result in data loss. Only change this setting if you know what you're doing, why this is important, and are okay with possible data loss or DB corruption. This may become the default in the future once it's been tested more thoroughly.
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.
If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well.
## EFF donations ## EFF donations
@@ -115,30 +122,6 @@ A portion of all donations to Pinchflat will be donated to the [Electronic Front
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 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.
## Development
Pinchflat is written in Elixir - a functional programming language that runs on the Erlang VM. It uses the Phoenix web framework and SQLite for the database. The frontend is mostly normal server-rendered HTML with a little Alpine.js as-needed.
Elixir is a personal favourite of mine and is ideal for building fault-tolerant systems. It's also a joy to work with and has a great community. If you're interested in contributing, I'd be happy to help you get started with Elixir - just open an issue with some questions and we can chat!
### Local setup
- `docker compose build --no-cache`
- `docker compose up -d && docker attach pinchflat-phx-1`
- After a few minutes the app should be accessible at `localhost:4008`
- Please let me know if you run into any hiccups here - I haven't had to bootstrap the app from scratch in a long time and I might have forgotten something
- Media downloads and config will be stored in the `tmp` directory. Not the OS's `/tmp` directory, but the one in the root of the project
### Running tests and linting
- Open a shell with `docker compose exec phx bash`
- Run `mix test` to run the tests
- Run `mix check` to do a full testing, linting, and static analysis pass
### Top tips
- Look for any module that ends in `*_worker.ex` - these are where the interesting stuff happens and you can trace back from there to see how the app works. `lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex` is a good place to start
## License ## License
See `LICENSE` file See `LICENSE` file
+24
View File
@@ -38,6 +38,30 @@ let liveSocket = new LiveSocket('/live', Socket, {
} }
}) })
window.copyTextToClipboard = async (text) => {
// Navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const textArea = document.createElement('textarea')
textArea.value = text
// Move textarea out of the viewport so it's not visible
textArea.style.position = 'absolute'
textArea.style.left = '-999999px'
document.body.prepend(textArea)
textArea.select()
try {
document.execCommand('copy')
} catch (error) {
console.error(error)
} finally {
textArea.remove()
}
}
}
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300)) window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300))
+7 -2
View File
@@ -20,10 +20,15 @@ config :pinchflat,
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
# Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth. # Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth.
# If either is unset, basic auth will not be used. # If either is unset, basic auth will not be used.
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), basic_auth_username: "",
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"), basic_auth_password: "",
expose_feed_endpoints: false,
file_watcher_poll_interval: 1000 file_watcher_poll_interval: 1000
config :pinchflat, Pinchflat.Repo,
journal_mode: :wal,
pool_size: 5
# Configures the endpoint # Configures the endpoint
config :pinchflat, PinchflatWeb.Endpoint, config :pinchflat, PinchflatWeb.Endpoint,
url: [host: "localhost", port: 8945], url: [host: "localhost", port: 8945],
+10 -62
View File
@@ -47,16 +47,13 @@ if config_env() == :prod do
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"]))
# We want to force _some_ level of useful logging in production # For running PF as a podcast host on self-hosted environments
acceptable_log_levels = ~w(debug info)a expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
if log_level in acceptable_log_levels do # For testing alternate journal modes (see issue #137)
config :logger, level: log_level journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
else
Logger.error("Invalid log level: #{log_level}. Defaulting to info.") config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
config :logger, level: :info
end
config :pinchflat, config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
@@ -64,11 +61,12 @@ if config_env() == :prod do
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: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY") dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: db_path, database: db_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") journal_mode: journal_mode
# The secret key base is used to sign/encrypt cookies and other secrets. # The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you # A default value is used in config/dev.exs and config/test.exs but you
@@ -102,7 +100,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, 0, 0, 0, 0}, ip: {0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000") port: String.to_integer(System.get_env("PORT") || "4000")
], ],
secret_key_base: secret_key_base secret_key_base: secret_key_base
@@ -121,54 +119,4 @@ if config_env() == :prod do
formatter: Logger.Formatter.new() formatter: Logger.Formatter.new()
}} }}
] ]
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :pinchflat, PinchflatWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :pinchflat, PinchflatWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :pinchflat, Pinchflat.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end end
-1
View File
@@ -17,7 +17,6 @@ config :pinchflat, Oban, testing: :manual
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)), database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
@@ -22,6 +22,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
thumbnail_options(media_item_with_preloads) ++ thumbnail_options(media_item_with_preloads) ++
metadata_options(media_profile) ++ metadata_options(media_profile) ++
quality_options(media_profile) ++ quality_options(media_profile) ++
sponsorblock_options(media_profile) ++
output_options(media_item_with_preloads) output_options(media_item_with_preloads)
{:ok, built_options} {:ok, built_options}
@@ -116,6 +117,17 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
end end
defp sponsorblock_options(media_profile) do
categories = media_profile.sponsorblock_categories
behaviour = media_profile.sponsorblock_behaviour
case {behaviour, categories} do
{_, []} -> []
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
{:disabled, _} -> []
end
end
defp output_options(media_item_with_preloads) do defp output_options(media_item_with_preloads) do
[ [
output: build_output_path_for(media_item_with_preloads.source) output: build_output_path_for(media_item_with_preloads.source)
@@ -5,8 +5,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
Many of these methods are made to be kickoff or be consumed by workers. Many of these methods are made to be kickoff or be consumed by workers.
""" """
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.FastIndexing.YoutubeRss alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.MediaIndexingWorker alias Pinchflat.FastIndexing.MediaIndexingWorker
@@ -27,7 +29,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
""" """
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source) {:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
existing_media_items = Media.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)
Enum.each(new_media_ids, fn media_id -> Enum.each(new_media_ids, fn media_id ->
@@ -61,6 +63,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
end end
end end
defp list_media_items_by_media_id_for(source, media_ids) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_ids(media_ids)
|> Repo.all()
end
defp create_media_item_from_url(source, url) do defp create_media_item_from_url(source, url) do
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url) {:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
+20 -102
View File
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Filesystem.FilesystemHelpers
@@ -21,33 +22,6 @@ defmodule Pinchflat.Media do
Repo.all(MediaItem) Repo.all(MediaItem)
end end
@doc """
Returns a list of media_items for a given source.
Returns [%MediaItem{}, ...].
"""
def list_media_items_for(%Source{} = source) do
MediaItem
|> where([mi], mi.source_id == ^source.id)
|> Repo.all()
end
@doc """
Fetches all media items belonging to a given source that have a media_id in the given list.
Useful for determining the what media items we DON'T already have for fast indexing.
NOTE: These queries are getting a little tedious. When I have the time, I should see about
implementing a query pattern and having these compose queries from a common base. This would
also let me compose simple queries in the module using them for one-off methods
Returns [%MediaItem{}, ...].
"""
def list_media_items_by_media_id_for(%Source{} = source, media_ids) do
MediaItem
|> where([mi], mi.source_id == ^source.id and mi.media_id in ^media_ids)
|> Repo.all()
end
@doc """ @doc """
Returns a list of pending media_items for a given source, where Returns a list of pending media_items for a given source, where
pending means the `media_filepath` is `nil` AND the media_item pending means the `media_filepath` is `nil` AND the media_item
@@ -60,13 +34,11 @@ defmodule Pinchflat.Media do
""" """
def list_pending_media_items_for(%Source{} = source, opts \\ []) do def list_pending_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil) limit = Keyword.get(opts, :limit, nil)
media_profile = Repo.preload(source, :media_profile).media_profile source = Repo.preload(source, :media_profile)
MediaItem MediaQuery.new()
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath)) |> MediaQuery.for_source(source)
|> where(^build_format_clauses(media_profile)) |> matching_download_criteria_for(source)
|> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -79,8 +51,9 @@ defmodule Pinchflat.Media do
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil) limit = Keyword.get(opts, :limit, nil)
MediaItem MediaQuery.new()
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath)) |> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -97,10 +70,9 @@ defmodule Pinchflat.Media do
def pending_download?(%MediaItem{} = media_item) do def pending_download?(%MediaItem{} = media_item) do
media_item = Repo.preload(media_item, source: :media_profile) media_item = Repo.preload(media_item, source: :media_profile)
MediaItem MediaQuery.new()
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath)) |> MediaQuery.with_id(media_item.id)
|> where(^build_format_clauses(media_item.source.media_profile)) |> matching_download_criteria_for(media_item.source)
|> where(^maybe_apply_cutoff_date(media_item.source))
|> Repo.exists?() |> Repo.exists?()
end end
@@ -119,20 +91,9 @@ defmodule Pinchflat.Media do
def search(search_term, opts) do def search(search_term, opts) do
limit = Keyword.get(opts, :limit, 50) limit = Keyword.get(opts, :limit, 50)
from(mi in MediaItem, MediaQuery.new()
join: mi_search_index in assoc(mi, :media_items_search_index), |> MediaQuery.matching_search_term(search_term)
where: fragment("media_items_search_index MATCH ?", ^search_term), |> Repo.maybe_limit(limit)
select_merge: %{
matching_search_term:
fragment("""
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
' ' ||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
""")
},
order_by: [desc: fragment("rank")],
limit: ^limit
)
|> Repo.all() |> Repo.all()
end end
@@ -240,54 +201,11 @@ defmodule Pinchflat.Media do
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end end
defp maybe_apply_cutoff_date(source) do defp matching_download_criteria_for(query, source_with_preloads) do
if source.download_cutoff_date do query
dynamic([mi], mi.upload_date >= ^source.download_cutoff_date) |> MediaQuery.with_no_media_filepath()
else |> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
dynamic(true) |> MediaQuery.with_format_preference(source_with_preloads.media_profile)
end |> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
end
defp maybe_apply_title_regex(source) do
if source.title_filter_regex do
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
else
dynamic(true)
end
end
defp build_format_clauses(media_profile) do
mapped_struct = Map.from_struct(media_profile)
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
case {attr, media_profile} do
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
dynamic(
[mi],
^dynamic and (mi.livestream == true or mi.short_form_content == true)
)
# Technically redundant, but makes the other clauses easier to parse
# (redundant because this condition is the same as the condition above, just flipped)
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
dynamic
{{:shorts_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.short_form_content == true)
{{:livestream_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.livestream == true)
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
dynamic([mi], ^dynamic and mi.short_form_content == false)
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
# return records with livestream: false
dynamic([mi], ^dynamic and mi.livestream == false)
_ ->
dynamic
end
end)
end end
end end
+10
View File
@@ -5,6 +5,7 @@ defmodule Pinchflat.Media.MediaItem do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
@@ -21,6 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
:source_id, :source_id,
:short_form_content, :short_form_content,
:upload_date, :upload_date,
:duration_seconds,
# these fields are captured only on download # these fields are captured only on download
:media_downloaded_at, :media_downloaded_at,
:media_filepath, :media_filepath,
@@ -32,6 +34,7 @@ defmodule Pinchflat.Media.MediaItem do
] ]
# Pretty much all the fields captured at index are required. # Pretty much all the fields captured at index are required.
@required_fields ~w( @required_fields ~w(
uuid
title title
original_url original_url
livestream livestream
@@ -42,6 +45,11 @@ defmodule Pinchflat.Media.MediaItem do
)a )a
schema "media_items" do schema "media_items" do
# 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
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :title, :string field :title, :string
field :media_id, :string field :media_id, :string
field :description, :string field :description, :string
@@ -50,6 +58,7 @@ defmodule Pinchflat.Media.MediaItem do
field :short_form_content, :boolean, default: false field :short_form_content, :boolean, default: false
field :media_downloaded_at, :utc_datetime field :media_downloaded_at, :utc_datetime
field :upload_date, :date field :upload_date, :date
field :duration_seconds, :integer
field :media_filepath, :string field :media_filepath, :string
field :media_size_bytes, :integer field :media_size_bytes, :integer
@@ -78,6 +87,7 @@ defmodule Pinchflat.Media.MediaItem do
media_item media_item
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false) |> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(@required_fields) |> validate_required(@required_fields)
|> unique_constraint([:media_id, :source_id]) |> unique_constraint([:media_id, :source_id])
end end
+120
View File
@@ -0,0 +1,120 @@
defmodule Pinchflat.Media.MediaQuery do
@moduledoc """
Query helpers for the Media context.
These methods are made to be one-ish liners used
to compose queries for media items. 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.
ALSO, this is me trying something new. If I like it,
I'll refactor other contexts to use this pattern.
"""
import Ecto.Query, warn: false
alias Pinchflat.Media.MediaItem
# Prefixes:
# - for_* - belonging to a certain record
# - with_* - for filtering based on full, concrete attributes
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
#
# Suffixes:
# - _for - the arg passed is an association record
def new do
MediaItem
end
def for_source(query, source) do
where(query, [mi], mi.source_id == ^source.id)
end
def with_id(query, id) do
where(query, [mi], mi.id == ^id)
end
def with_media_ids(query, media_ids) do
where(query, [mi], mi.media_id in ^media_ids)
end
def with_media_filepath(query) do
where(query, [mi], not is_nil(mi.media_filepath))
end
def with_no_media_filepath(query) do
where(query, [mi], is_nil(mi.media_filepath))
end
def with_upload_date_after(query, nil), do: query
def with_upload_date_after(query, date) do
where(query, [mi], mi.upload_date >= ^date)
end
def matching_title_regex(query, nil), do: query
def matching_title_regex(query, regex) do
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex))
end
def matching_search_term(query, nil), do: query
def matching_search_term(query, term) do
from(mi in query,
join: mi_search_index in assoc(mi, :media_items_search_index),
where: fragment("media_items_search_index MATCH ?", ^term),
select_merge: %{
matching_search_term:
fragment("""
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
' ' ||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
""")
},
order_by: [desc: fragment("rank")]
)
end
# NOTE: this method breaks the contract set by other methods in that it
# takes a media_profile struct instead of taking just the attributes it
# cares about. Consider refactoring but low priority.
def with_format_preference(query, media_profile) do
mapped_struct = Map.from_struct(media_profile)
finders =
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
case {attr, media_profile} do
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
dynamic(
[mi],
^dynamic and (mi.livestream == true or mi.short_form_content == true)
)
# Technically redundant, but makes the other clauses easier to parse
# (redundant because this condition is the same as the condition above, just flipped)
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
dynamic
{{:shorts_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.short_form_content == true)
{{:livestream_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.livestream == true)
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
dynamic([mi], ^dynamic and mi.short_form_content == false)
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
# return records with livestream: false
dynamic([mi], ^dynamic and mi.livestream == false)
_ ->
dynamic
end
end)
where(query, ^finders)
end
end
+5 -1
View File
@@ -30,7 +30,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
original_url: metadata["original_url"], original_url: metadata["original_url"],
description: metadata["description"], description: metadata["description"],
media_filepath: metadata["filepath"], media_filepath: metadata["filepath"],
livestream: metadata["was_live"] livestream: metadata["was_live"],
duration_seconds: metadata["duration"] && round(metadata["duration"])
} }
end end
@@ -80,6 +81,9 @@ defmodule Pinchflat.Metadata.MetadataParser do
} }
end end
# NOTE: this should not be needed, but it is due to a bug in yt-dlp.
# Can remove once this is resolved:
# https://github.com/yt-dlp/yt-dlp/issues/9445#issuecomment-2018724344
defp filepath_if_exists(nil), do: nil defp filepath_if_exists(nil), do: nil
defp filepath_if_exists(filepath) do defp filepath_if_exists(filepath) do
@@ -32,6 +32,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
@doc """ @doc """
Fetches and stores various forms of metadata for a source: Fetches and stores various forms of metadata for a source:
- Attributes like `description`
- JSON metadata for internal use - JSON metadata for internal use
- The series directory for the source - The series directory for the source
- The NFO file for the source (if specified) - The NFO file for the source (if specified)
@@ -59,6 +60,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
%{ %{
series_directory: series_directory, series_directory: series_directory,
nfo_filepath: store_source_nfo(source, series_directory, source_metadata), nfo_filepath: store_source_nfo(source, series_directory, source_metadata),
description: source_metadata["description"],
metadata: Map.merge(%{metadata_filepath: source_metadata_filepath}, metadata_image_attrs) metadata: Map.merge(%{metadata_filepath: source_metadata_filepath}, metadata_image_attrs)
}, },
source_image_attrs source_image_attrs
+75
View File
@@ -0,0 +1,75 @@
defmodule Pinchflat.Podcasts.PodcastHelpers do
@moduledoc """
Methods for fetching postcast-related data from a source
or its media items
"""
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata
@doc """
Returns a list of media items that have been downloaded to disk
and have been proven to still exist there.
Useful for podcasts since we don't want to serve media that
has been deleted or moved, but it's also fairly generally useful
so I could see this being moved in the future.
Options:
- limit: integer - the maximum number of media items to return
Returns: [%MediaItem{}]
"""
def persisted_media_items_for(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 500)
source
|> Media.list_downloaded_media_items_for(limit: limit)
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
end
@doc """
Selects a cover image for a source based on the source's metadata
and the metadata of the media items associated with the source. Also
ensures images exist on disk.
Only one media item should need to be returned since this is using the
internal metadata which, so long as the media_item was _downloaded_, should
be guaranteed to exist.
Prefers the source's poster, then fanart, then the media item's thumbnail.
Returns: {:ok, filepath} | {:error, :no_suitable_image}
"""
def select_cover_image(source, media_items) do
source_with_preloads = Repo.preload(source, :metadata)
source_with_preloads
|> get_images_by_preference(media_items)
|> Enum.reject(&is_nil(&1))
|> Enum.find(&File.exists?/1)
|> case do
nil -> {:error, :no_suitable_image}
filepath -> {:ok, filepath}
end
end
defp get_images_by_preference(source_with_preloads, []) do
source_metadata = source_with_preloads.metadata || %SourceMetadata{}
[
source_metadata.poster_filepath,
source_metadata.fanart_filepath
]
end
defp get_images_by_preference(source_with_preloads, [media_item | _]) do
media_item_with_preloads = Repo.preload(media_item, :metadata)
media_item_metadata = media_item_with_preloads.metadata || %MediaMetadata{}
source_images = get_images_by_preference(source_with_preloads, [])
source_images ++ [media_item_metadata.thumbnail_filepath]
end
end
+139
View File
@@ -0,0 +1,139 @@
defmodule Pinchflat.Podcasts.RssFeedBuilder do
@moduledoc """
Methods for building an RSS feed for a source and its media items.
"""
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes
@doc """
Builds an RSS feed for a given source and its media items.
Only MediaItems that have been persisted will be included in the feed.
## Options:
- `:limit` - The maximum number of media items to include in the feed. Defaults to 300.
Returns an XML document as a string.
"""
def build(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 300)
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
build_source_xml(source, media_items, url_base)
end
defp build_source_xml(source, media_items, url_base) do
media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1, url_base))
# "caching" the image path since it requires some DB calls and is used twice
feed_image_path = feed_image_path(url_base, source, media_items)
# Useful: resources:
# - https://validator.w3.org/feed/#validate_by_input
# - https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md
# - https://podba.se/validate
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>#{safe(source.custom_name)}</title>
<link>#{source.original_url}</link>
<description>#{safe(source.description)}</description>
<category>TV &amp; Film</category>
<generator>Generated by Pinchflat</generator>
<language>en-us</language>
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
<atom:link href="#{generate_self_link(url_base, source)}" rel="self" type="application/rss+xml" />
<podcast:locked>yes</podcast:locked>
<podcast:guid>#{source.uuid}</podcast:guid>
<image>
<url>#{feed_image_path}</url>
<title>#{safe(source.custom_name)}</title>
<link>#{source.original_url}</link>
</image>
<itunes:author>#{safe(source.custom_name)}</itunes:author>
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
<itunes:block>yes</itunes:block>
<itunes:image href="#{feed_image_path}"></itunes:image>
<itunes:explicit>false</itunes:explicit>
<itunes:category text="TV &amp; Film"></itunes:category>
#{Enum.join(media_item_xml, "\n")}
</channel>
</rss>
"""
end
defp build_media_item_xml(source, media_item, url_base) do
"""
<item>
<guid isPermaLink="false">#{media_item.uuid}</guid>
<title>#{safe(media_item.title)}</title>
<link>#{media_item.original_url}</link>
<description>#{safe(media_item.description)}</description>
<pubDate>#{generate_upload_date(media_item)}</pubDate>
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
<enclosure
url="#{media_stream_path(url_base, media_item)}"
length="#{media_item.media_size_bytes}"
type="#{MIME.from_path(media_item.media_filepath)}"
/>
<itunes:author>#{safe(source.custom_name)}</itunes:author>
<itunes:subtitle>#{safe(media_item.title)}</itunes:subtitle>
<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>
<itunes:explicit>false</itunes:explicit>
</item>
"""
end
defp safe(nil), do: ""
defp safe(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
defp generate_self_link(url_base, source) do
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
end
defp media_stream_path(url_base, media_item) do
extension = Path.extname(media_item.media_filepath)
Path.join(url_base, "#{media_route(:stream, media_item.uuid)}#{extension}")
end
defp feed_image_path(url_base, source, media_items) do
case PodcastHelpers.select_cover_image(source, media_items) do
{:error, _} ->
""
{:ok, filepath} ->
extension = Path.extname(filepath)
Path.join(url_base, "#{podcast_route(:feed_image, source.uuid)}#{extension}")
end
end
defp generate_upload_date(media_item) do
media_item.upload_date
|> DatetimeUtils.date_to_datetime()
|> Calendar.strftime(@datetime_format)
end
defp podcast_route(action, params) do
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
end
defp media_route(action, params) do
Routes.media_item_path(PinchflatWeb.Endpoint, action, params)
end
end
+4
View File
@@ -21,6 +21,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
download_metadata download_metadata
embed_metadata embed_metadata
download_nfo download_nfo
sponsorblock_behaviour
sponsorblock_categories
shorts_behaviour shorts_behaviour
livestream_behaviour livestream_behaviour
preferred_resolution preferred_resolution
@@ -47,6 +49,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :embed_metadata, :boolean, default: false field :embed_metadata, :boolean, default: false
field :download_nfo, :boolean, default: false field :download_nfo, :boolean, default: false
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :remove], default: :disabled
field :sponsorblock_categories, {:array, :string}, default: []
# NOTE: these do NOT speed up indexing - the indexer still has to go # NOTE: these do NOT speed up indexing - the indexer still has to go
# through the entire collection to determine if a media is a short or # through the entire collection to determine if a media is a short or
# a livestream. # a livestream.
+3 -2
View File
@@ -25,14 +25,15 @@ defmodule Pinchflat.Release do
def check_file_permissions do def check_file_permissions do
load_app() load_app()
directories = [ directories =
Enum.uniq([
"/config", "/config",
"/downloads", "/downloads",
Application.get_env(:pinchflat, :media_directory), Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory), Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :extras_directory), Application.get_env(:pinchflat, :extras_directory),
Application.get_env(:pinchflat, :metadata_directory) Application.get_env(:pinchflat, :metadata_directory)
] ])
Enum.each(directories, fn dir -> Enum.each(directories, fn dir ->
Logger.info("Checking permissions for #{dir}") Logger.info("Checking permissions for #{dir}")
+9
View File
@@ -17,6 +17,7 @@ defmodule Pinchflat.Sources.Source do
collection_id collection_id
collection_type collection_type
custom_name custom_name
description
nfo_filepath nfo_filepath
poster_filepath poster_filepath
fanart_filepath fanart_filepath
@@ -47,6 +48,7 @@ defmodule Pinchflat.Sources.Source do
@pre_insert_required_fields @initially_required_fields ++ @pre_insert_required_fields @initially_required_fields ++
~w( ~w(
uuid
custom_name custom_name
collection_name collection_name
collection_id collection_id
@@ -54,7 +56,13 @@ defmodule Pinchflat.Sources.Source do
)a )a
schema "sources" do schema "sources" do
# 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
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :custom_name, :string field :custom_name, :string
field :description, :string
field :collection_name, :string field :collection_name, :string
field :collection_id, :string field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist] field :collection_type, Ecto.Enum, values: [:channel, :playlist]
@@ -96,6 +104,7 @@ defmodule Pinchflat.Sources.Source do
source source
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end) |> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields) |> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false) |> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
end end
+4 -2
View File
@@ -9,6 +9,7 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@@ -112,8 +113,9 @@ defmodule Pinchflat.Sources do
delete_files = Keyword.get(opts, :delete_files, false) delete_files = Keyword.get(opts, :delete_files, false)
Tasks.delete_tasks_for(source) Tasks.delete_tasks_for(source)
source MediaQuery.new()
|> Media.list_media_items_for() |> MediaQuery.for_source(source)
|> Repo.all()
|> Enum.each(fn media_item -> |> Enum.each(fn media_item ->
Media.delete_media_item(media_item, delete_files: delete_files) Media.delete_media_item(media_item, delete_files: delete_files)
end) end)
+17
View File
@@ -0,0 +1,17 @@
defmodule Pinchflat.Utils.DatetimeUtils do
@moduledoc """
Utility methods for working with dates and datetimes
"""
@doc """
Converts a Date to a DateTime
Returns %DateTime{}
"""
def date_to_datetime(date) do
date
|> Date.to_gregorian_days()
|> Kernel.*(86_400)
|> DateTime.from_gregorian_seconds()
end
end
+5 -2
View File
@@ -10,7 +10,8 @@ defmodule Pinchflat.YtDlp.Media do
:original_url, :original_url,
:livestream, :livestream,
:short_form_content, :short_form_content,
:upload_date :upload_date,
:duration_seconds
] ]
defstruct [ defstruct [
@@ -20,7 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
:original_url, :original_url,
:livestream, :livestream,
:short_form_content, :short_form_content,
:upload_date :upload_date,
:duration_seconds
] ]
alias __MODULE__ alias __MODULE__
@@ -86,6 +88,7 @@ defmodule Pinchflat.YtDlp.Media do
description: response["description"], description: response["description"],
original_url: response["webpage_url"], original_url: response["webpage_url"],
livestream: response["was_live"], livestream: response["was_live"],
duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response), short_form_content: response["webpage_url"] && short_form_content?(response),
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"]) upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
} }
+1 -1
View File
@@ -21,7 +21,7 @@ defmodule PinchflatWeb do
def router do def router do
quote do quote do
use Phoenix.Router, helpers: false use Phoenix.Router, helpers: true
# Import common connection and controller functions to use in pipelines # Import common connection and controller functions to use in pipelines
import Plug.Conn import Plug.Conn
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
""" """
use Phoenix.Component use Phoenix.Component, global_prefixes: ~w(x-)
import PinchflatWeb.Gettext import PinchflatWeb.Gettext
@@ -251,7 +251,7 @@ defmodule PinchflatWeb.CoreComponents do
attr :type, :string, attr :type, :string,
default: "text", default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password values: ~w(checkbox color date datetime-local email file hidden month number password
toggle range radio search select tel text textarea time url week) checkbox_group toggle range radio search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
@@ -304,6 +304,33 @@ defmodule PinchflatWeb.CoreComponents do
""" """
end end
def input(%{type: "checkbox_group"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
</.label>
<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">
<input
type="checkbox"
id={"#{@id}-#{option_value}"}
name={"#{@name}[]"}
value={option_value}
checked={option_value in @value}
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">
<%= option_name %>
</label>
</div>
</section>
<.help :if={@help}><%= @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "toggle"} = assigns) do def input(%{type: "toggle"} = assigns) do
assigns = assigns =
assign_new(assigns, :checked, fn -> assign_new(assigns, :checked, fn ->
@@ -347,8 +374,9 @@ defmodule PinchflatWeb.CoreComponents do
id={@id} id={@id}
name={@name} name={@name}
class={[ class={[
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition", "relative z-20 w-full appearance-none rounded border border-form-strokedark py-3 pl-5 pr-12 outline-none transition",
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white", "focus:border-primary active:border-primary bg-form-input text-black text-white",
"disabled:text-opacity-50 disabled:cursor-not-allowed disabled:border-black",
@inputclass @inputclass
]} ]}
multiple={@multiple} multiple={@multiple}
@@ -600,6 +628,10 @@ defmodule PinchflatWeb.CoreComponents do
_ -> _ ->
true true
end) end)
|> Enum.map(fn
{k, v} when is_list(v) -> {k, Enum.join(v, ", ")}
rest -> rest
end)
assigns = assign(assigns, iterable_attributes: attrs) assigns = assign(assigns, iterable_attributes: attrs)
@@ -654,10 +686,11 @@ defmodule PinchflatWeb.CoreComponents do
""" """
attr :name, :string, required: true attr :name, :string, required: true
attr :class, :string, default: nil attr :class, :string, default: nil
attr :rest, :global
def icon(%{name: "hero-" <> _} = assigns) do def icon(%{name: "hero-" <> _} = assigns) do
~H""" ~H"""
<span class={[@name, @class]} /> <span class={[@name, @class]} {@rest} />
""" """
end end
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.CustomComponents.ButtonComponents do defmodule PinchflatWeb.CustomComponents.ButtonComponents do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.Component, global_prefixes: ~w(x-)
alias PinchflatWeb.CoreComponents
@doc """ @doc """
Render a button Render a button
@@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
</button> </button>
""" """
end end
@doc """
Render a dropdown based off a button
## Examples
<.button_dropdown text="Actions">
<:option>TEST</:option>
</.button_dropdown>
"""
attr :text, :string, required: true
attr :class, :string, default: ""
slot :option, required: true
def button_dropdown(assigns) do
~H"""
<div x-data="{ dropdownOpen: false }" class={["relative flex", @class]}>
<span
x-on:click.prevent="dropdownOpen = !dropdownOpen"
class={[
"cursor-pointer inline-flex gap-2.5 rounded-md bg-primary px-5.5 py-3",
"font-medium text-white hover:bg-opacity-95"
]}
>
<%= @text %>
<CoreComponents.icon
name="hero-chevron-down"
class="fill-current duration-200 ease-linear mt-1"
x-bind:class="dropdownOpen && 'rotate-180'"
/>
</span>
<div
x-show="dropdownOpen"
x-on:click.outside="dropdownOpen = false"
class="absolute left-0 top-full z-40 mt-2 w-full rounded-md bg-black py-3 shadow-card"
>
<ul class="flex flex-col">
<li :for={option <- @option}>
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
<%= render_slot(option) %>
</span>
</li>
</ul>
</div>
</div>
"""
end
end end
@@ -9,24 +9,31 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
attr :title, :string, required: true attr :title, :string, required: true
end end
slot :tab_append, required: false
def tabbed_layout(assigns) do def tabbed_layout(assigns) do
~H""" ~H"""
<div <div
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }" x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
class="w-full" class="w-full"
> >
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10"> <header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
<div class="flex flex-wrap gap-5 sm:gap-10">
<a <a
:for={{tab, idx} <- Enum.with_index(@tab)} :for={{tab, idx} <- Enum.with_index(@tab)}
href="#" href="#"
@click.prevent={"openTab = #{idx}"} @click.prevent={"openTab = #{idx}"}
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"} x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
class="border-b-2 py-4 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> <div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
<%= render_slot(@tab_append) %>
</div>
</header>
<div class="mt-4">
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed"> <div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
<%= render_slot(tab) %> <%= render_slot(tab) %>
</div> </div>
@@ -57,6 +57,11 @@
<.icon name="hero-currency-dollar" /> Donate <.icon name="hero-currency-dollar" /> Donate
</span> </span>
</li> </li>
<li>
<span class="group relative flex items-center gap-2.5 px-4 py-2 text-sm">
v<%= Application.spec(:pinchflat)[:vsn] %>
</span>
</li>
</ul> </ul>
</nav> </nav>
</section> </section>
@@ -1,8 +1,11 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
require Logger
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_item = media_item =
@@ -29,4 +32,69 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|> put_flash(:info, flash_message) |> put_flash(:info, flash_message)
|> redirect(to: ~p"/sources/#{media_item.source_id}") |> redirect(to: ~p"/sources/#{media_item.source_id}")
end end
# See here for details on streaming files and range requests:
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
#
# Uses the UUID instead of the ID to avoid enumeration attacks
# since streaming is a public endpoint (ie: no auth required)
def stream(conn, %{"uuid" => uuid}) do
media_item = Repo.get_by!(MediaItem, uuid: uuid)
if File.exists?(media_item.media_filepath) do
file_size = File.stat!(media_item.media_filepath).size
mime_type = MIME.from_path(media_item.media_filepath)
case parse_range(conn, file_size) do
{:ok, {start_pos, end_pos}} ->
Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{end_pos}")
length = end_pos - start_pos + 1
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|> put_resp_header("content-length", to_string(length))
|> send_file(206, media_item.media_filepath, start_pos, length)
{:error, :invalid_range} ->
Logger.debug("Invalid range request for media item: #{media_item.uuid} - serving full file")
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("content-length", to_string(file_size))
|> put_resp_header("accept-ranges", "bytes")
|> send_file(200, media_item.media_filepath)
end
else
send_resp(conn, 404, "File not found")
end
end
defp parse_range(conn, file_size) do
with [range_header | _] <- get_req_header(conn, "range"),
["bytes", range] <- String.split(range_header, "="),
[start_pos, end_pos] <- String.split(range, "-") do
validate_range(start_pos, end_pos, file_size)
else
_ -> {:error, :invalid_range}
end
end
defp validate_range(start_pos, end_pos, file_size) do
case {Integer.parse(start_pos), Integer.parse(end_pos)} do
{:error, :error} ->
{:error, :invalid_range}
{{start_pos, _}, :error} ->
{:ok, {start_pos, file_size - 1}}
# See RFC7233
{{start_pos, _}, {end_pos, _}} when end_pos >= file_size ->
{:ok, {start_pos, file_size - 1}}
{{start_pos, _}, {end_pos, _}} ->
{:ok, {start_pos, end_pos}}
end
end
end end
@@ -2,4 +2,16 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
embed_templates "media_item_html/*" embed_templates "media_item_html/*"
def media_file_exists?(media_item) do
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
end
def media_type(media_item) do
case Path.extname(media_item.media_filepath) do
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
ext when ext in [".mp3", ".m4a"] -> :audio
_ -> :unknown
end
end
end end
@@ -0,0 +1,13 @@
<%= if media_type(@media_item) == :video do %>
<video controls class="max-h-96 w-full lg:w-2/3 xl:w-1/2">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
Your browser does not support the video element.
</video>
<% end %>
<%= if media_type(@media_item) == :audio do %>
<audio controls class="w-full lg:w-2/3 xl:w-1/2">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
<% end %>
@@ -8,11 +8,30 @@
</h2> </h2>
</div> </div>
</div> </div>
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5"> <div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
Delete Files
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes"> <:tab title="Attributes">
<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 %>
<h3 class="font-bold text-xl">Preview</h3>
<.media_preview media_item={@media_item} />
<% end %>
<h3 class="font-bold text-xl">Attributes</h3> <h3 class="font-bold text-xl">Attributes</h3>
<section> <section>
<strong>Source:</strong> <strong>Source:</strong>
@@ -23,18 +42,6 @@
<.list_items_from_map map={Map.from_struct(@media_item)} /> <.list_items_from_map map={Map.from_struct(@media_item)} />
</div> </div>
<section class="flex justify-center my-10">
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Files
</.button>
</.link>
</section>
</:tab> </:tab>
<:tab title="Tasks"> <:tab title="Tasks">
<%= if match?([_|_], @media_item.tasks) do %> <%= if match?([_|_], @media_item.tasks) do %>
@@ -32,6 +32,26 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
] ]
end end
def friendly_sponsorblock_options do
[
{"Disabled (default)", "disabled"},
{"Remove Segments", "remove"}
]
end
def frieldly_sponsorblock_categories do
[
{"Sponsor", "sponsor"},
{"Intro/Intermission", "intro"},
{"Outro/Credits", "outro"},
{"Self Promotion", "selfpromo"},
{"Preview/Recap", "preview"},
{"Filler Tangent", "filler"},
{"Interaction Reminder", "interaction"},
{"Non-music Section", "music_offtopic"}
]
end
def custom_output_template_options do def custom_output_template_options do
%{ %{
upload_day: nil, upload_day: nil,
@@ -38,7 +38,7 @@
presets: { presets: {
default: 'Default', default: 'Default',
media_center: 'TV Shows', media_center: 'TV Shows',
audio: 'Audio', audio: 'Music',
archiving: 'Archiving' archiving: 'Archiving'
} }
}"> }">
@@ -238,6 +238,36 @@
/> />
</section> </section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
SponsorBlock Options
</h3>
<section x-data="{ sponsorblockBehaviour: null }">
<section x-data="{ presets: { default: 'disabled', media_center: 'disabled', audio: 'disabled', archiving: 'disabled' } }">
<.input
field={f[:sponsorblock_behaviour]}
options={friendly_sponsorblock_options()}
type="select"
label="SponsorBlock Behaviour"
help="Action to take when SponsorBlock segments are found. 'Disabled' won't take any action"
x-model="sponsorblockBehaviour"
x-init="
sponsorblockBehaviour = $el.value
$watch('selectedPreset', p => p && ($el.value = presets[p]))
"
/>
</section>
<section x-show="sponsorblockBehaviour !== 'disabled'" x-transition>
<.input
field={f[:sponsorblock_categories]}
options={frieldly_sponsorblock_categories()}
type="checkbox_group"
label="SponsorBlock Categories"
/>
</section>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button> <.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
</section> </section>
@@ -19,33 +19,35 @@
<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 overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab title="Attributes"> <:tab_append>
<div class="flex flex-col gap-10 text-white"> <.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3> <:option>
<.list_items_from_map map={Map.from_struct(@media_profile)} />
</div>
<section class="flex flex-col md:flex-row items-center md:justify-around my-10">
<.link <.link
href={~p"/media_profiles/#{@media_profile}"} href={~p"/media_profiles/#{@media_profile}"}
method="delete" method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone." data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
> >
<.button color="bg-meta-1" rounding="rounded-lg"> Delete Profile
Delete Profile and its Sources
</.button>
</.link> </.link>
</:option>
<:option>
<.link <.link
href={~p"/media_profiles/#{@media_profile}?delete_files=true"} href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
method="delete" method="delete"
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone." data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0" class="mt-5 md:mt-0"
> >
<.button color="bg-meta-1" rounding="rounded-lg"> Delete Profile + Files
Delete Profile, Sources, and Files
</.button>
</.link> </.link>
</section> </:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes">
<div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
<.list_items_from_map map={Map.from_struct(@media_profile)} />
</div>
</:tab> </:tab>
<:tab title="Sources"> <:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white"> <.table rows={@media_profile.sources} table_class="text-black dark:text-white">
@@ -0,0 +1,38 @@
defmodule PinchflatWeb.Podcasts.PodcastController do
use PinchflatWeb, :controller
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Sources.Source
alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.PodcastHelpers
def rss_feed(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid)
url_base = url(conn, ~p"/")
xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base)
conn
|> put_resp_content_type("application/rss+xml")
|> put_resp_header("content-disposition", "inline")
|> send_resp(200, xml)
end
def feed_image(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid)
# This provides a fallback image if the source has none.
# We only need one since we're using the internal metadata image which
# we know exists.
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
case PodcastHelpers.select_cover_image(source, media_items) do
{:error, _} ->
send_resp(conn, 404, "Image not found")
{:ok, filepath} ->
conn
|> put_resp_content_type(MIME.from_path(filepath))
|> send_file(200, filepath)
end
end
end
@@ -1,12 +1,15 @@
defmodule PinchflatWeb.Sources.SourceController do defmodule PinchflatWeb.Sources.SourceController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
import Ecto.Query, warn: false
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
def index(conn, _params) do def index(conn, _params) do
sources = Repo.preload(Sources.list_sources(), :media_profile) sources = Repo.preload(Sources.list_sources(), :media_profile)
@@ -54,7 +57,8 @@ defmodule PinchflatWeb.Sources.SourceController do
source: source, source: source,
pending_tasks: pending_tasks, pending_tasks: pending_tasks,
pending_media: pending_media, pending_media: pending_media,
downloaded_media: downloaded_media downloaded_media: downloaded_media,
total_downloaded: total_downloaded_for(source)
) )
end end
@@ -104,6 +108,15 @@ defmodule PinchflatWeb.Sources.SourceController do
Profiles.list_media_profiles() Profiles.list_media_profiles()
end end
# NOTE: should move this out of the controller
# once I finally add some query fragment layer
defp total_downloaded_for(source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.aggregate(:count, :id)
end
defp get_onboarding_layout do defp get_onboarding_layout do
if Settings.get!(:onboarding) do if Settings.get!(:onboarding) do
{Layouts, :onboarding} {Layouts, :onboarding}
@@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do
{"Monthly", 30 * 24 * 60} {"Monthly", 30 * 24 * 60}
] ]
end end
def rss_feed_url(conn, source) do
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end
end end
@@ -19,6 +19,46 @@
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5"> <div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
copied = true
setTimeout(() => copied = false, 4000)
"}
>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
Delete Source
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Source + Files
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes"> <:tab title="Attributes">
<div class="flex flex-col gap-10 text-white"> <div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-lg">Attributes</h3> <h3 class="font-bold text-lg">Attributes</h3>
@@ -31,28 +71,6 @@
<.list_items_from_map map={Map.from_struct(@source)} /> <.list_items_from_map map={Map.from_struct(@source)} />
</div> </div>
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Source
</.button>
</.link>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Source and Files
</.button>
</.link>
</section>
</:tab> </:tab>
<:tab title="Pending Media"> <:tab title="Pending Media">
<%= if match?([_|_], @pending_media) do %> <%= if match?([_|_], @pending_media) do %>
@@ -71,7 +89,7 @@
</:tab> </:tab>
<:tab title="Downloaded Media"> <:tab title="Downloaded Media">
<%= if match?([_|_], @downloaded_media) do %> <%= if match?([_|_], @downloaded_media) do %>
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4> <h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
<.table rows={@downloaded_media} table_class="text-black dark:text-white"> <.table rows={@downloaded_media} table_class="text-black dark:text-white">
<:col :let={media_item} label="Title"> <:col :let={media_item} label="Title">
<%= StringUtils.truncate(media_item.title, 50) %> <%= StringUtils.truncate(media_item.title, 50) %>
@@ -39,24 +39,32 @@
Indexing Options Indexing Options
</h3> </h3>
<section x-data="{ fastIndexingEnabled: null }">
<.input <.input
field={f[:index_frequency_minutes]} field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()} options={friendly_index_frequencies()}
type="select" type="select"
label="Index Frequency" label="Index Frequency"
x-bind:disabled="fastIndexingEnabled == true"
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting" help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
/> />
<%!-- TODO: use Alpine to disable the index frequency when fast indexing is enabled --%>
<div phx-click={show_modal("upgrade-modal")}> <div phx-click={show_modal("upgrade-modal")}>
<.input <.input
field={f[:fast_index]} field={f[:fast_index]}
type="toggle" type="toggle"
label="Use Fast Indexing" label="Use Fast Indexing"
label_suffix="(pro)" label_suffix="(pro)"
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info" help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
x-init="
// `enabled` is the data attribute that the toggle uses internally
fastIndexingEnabled = enabled
$watch('enabled', value => fastIndexingEnabled = !!value)
"
/> />
</div> </div>
</section>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <h3 class="mt-8 text-2xl text-black dark:text-white">
Downloading Options Downloading Options
+49
View File
@@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do
plug Plug.MethodOverride plug Plug.MethodOverride
plug Plug.Head plug Plug.Head
plug Plug.Session, @session_options plug Plug.Session, @session_options
plug :override_base_url
plug :strip_trailing_extension
plug PinchflatWeb.Router plug PinchflatWeb.Router
# URLs need to be generated using the host of the current page being accessed
# for things like Podcast RSS feeds to contain links to the right location.
#
# Normally you'd set the `url` option in the Endpoint configuration, but
# since this is self-hosted and often accessed at multiple different URLs,
# that would probably be more difficult for end-users to set up than just
# having the application figure it out.
defp override_base_url(conn, _opts) do
new_scheme =
case get_req_header(conn, "x-forwarded-proto") do
[scheme] -> scheme
_ -> to_string(conn.scheme)
end
new_port = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
new_base_url = "#{new_scheme}://#{conn.host}#{new_port}"
Phoenix.Controller.put_router_url(conn, new_base_url)
end
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
defp strip_trailing_extension(conn, _opts) do
path =
conn.path_info
|> List.last()
|> String.split(".")
|> Enum.reverse()
case path do
[_] ->
conn
[_format | fragments] ->
new_path =
fragments
|> Enum.reverse()
|> Enum.join(".")
path_fragments = List.replace_at(conn.path_info, -1, new_path)
%{conn | path_info: path_fragments}
end
end
end end
+24 -4
View File
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.Router do defmodule PinchflatWeb.Router do
use PinchflatWeb, :router use PinchflatWeb, :router
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
# the extension from the path
pipeline :browser do pipeline :browser do
plug :basic_auth plug :basic_auth
plug :accepts, ["html"] plug :accepts, ["html"]
@@ -15,6 +17,10 @@ defmodule PinchflatWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :feeds do
plug :maybe_basic_auth
end
scope "/", PinchflatWeb do scope "/", PinchflatWeb do
pipe_through :browser pipe_through :browser
@@ -28,10 +34,16 @@ defmodule PinchflatWeb.Router do
end end
end end
# Other scopes may use custom stacks. # Routes in here _may not be_ protected by basic auth. This is necessary for
# scope "/api", PinchflatWeb do # media streaming to work for RSS podcast feeds.
# pipe_through :api scope "/", PinchflatWeb do
# end pipe_through :feeds
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
end
# Enable LiveDashboard and Swoosh mailbox preview in development # Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:pinchflat, :dev_routes) do if Application.compile_env(:pinchflat, :dev_routes) do
@@ -50,6 +62,14 @@ defmodule PinchflatWeb.Router do
end end
end end
defp maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end
defp basic_auth(conn, _opts) do defp basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username) username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password) password = Application.get_env(:pinchflat, :basic_auth_password)
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.2", version: "0.1.5",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -0,0 +1,13 @@
defmodule Pinchflat.Repo.Migrations.AddUuidToSourceAndMedia do
use Ecto.Migration
def change do
alter table(:sources) do
add :uuid, :uuid
end
alter table(:media_items) do
add :uuid, :uuid
end
end
end
@@ -0,0 +1,13 @@
defmodule Pinchflat.Repo.Migrations.BackfillContentUuids do
use Ecto.Migration
def up do
execute("UPDATE sources SET uuid = gen_random_uuid() WHERE uuid IS NULL")
execute("UPDATE media_items SET uuid = gen_random_uuid() WHERE uuid IS NULL")
end
def down do
execute("UPDATE sources SET uuid = NULL")
execute("UPDATE media_items SET uuid = NULL")
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddDescriptionToSource do
use Ecto.Migration
def change do
alter table(:sources) do
add :description, :string
end
end
end
@@ -0,0 +1,8 @@
defmodule Pinchflat.Repo.Migrations.AddIndexToUuidFields do
use Ecto.Migration
def change do
create unique_index(:sources, [:uuid])
create unique_index(:media_items, [:uuid])
end
end
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.AddSponsorblockToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :sponsorblock_behaviour, :string, default: "disabled"
add :sponsorblock_categories, {:array, :string}, default: []
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddDurationToMediaItems do
use Ecto.Migration
def change do
alter table(:media_items) do
add :duration_seconds, :integer
end
end
end
+4 -1
View File
@@ -75,6 +75,8 @@ RUN mix release
# the compiled release and other runtime necessities # the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE} FROM ${RUNNER_IMAGE}
ARG PORT=8945
RUN apt-get update -y RUN apt-get update -y
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
ffmpeg curl git openssh-client nano ffmpeg curl git openssh-client nano
@@ -101,8 +103,9 @@ RUN chown nobody /config /downloads
# set runner ENV # set runner ENV
ENV MIX_ENV="prod" ENV MIX_ENV="prod"
ENV PORT=8945 ENV PORT=${PORT}
ENV RUN_CONTEXT="selfhosted" ENV RUN_CONTEXT="selfhosted"
EXPOSE ${PORT}
# Only copy the final release from the build stage # Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./ COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./
@@ -222,6 +222,45 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end end
end end
describe "build/1 when testing sponsorblock options" do
test "includes :sponsorblock_remove option when specified", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :remove,
sponsorblock_categories: ["sponsor", "intro"]
})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:sponsorblock_remove, "sponsor,intro"} in res
end
test "does not include :sponsorblock_remove option without categories", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :remove,
sponsorblock_categories: []
})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res
refute {:sponsorblock_remove, []} in res
refute :sponsorblock_remove in res
end
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{sponsorblock_behaviour: :disabled})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res
refute {:sponsorblock_remove, []} in res
refute :sponsorblock_remove in res
end
end
describe "build_output_path_for/1" do describe "build_output_path_for/1" do
test "builds an output path for a source", %{media_item: media_item} do test "builds an output path for a source", %{media_item: media_item} do
path = DownloadOptionBuilder.build_output_path_for(media_item.source) path = DownloadOptionBuilder.build_output_path_for(media_item.source)
@@ -35,7 +35,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item) assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end end
test "it saves the metadata filepatha to the database", %{media_item: media_item} do test "it saves the metadata filepath to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -93,6 +93,12 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
end end
test "it extracts the duration_seconds", %{media_item: media_item} do
assert media_item.duration_seconds == nil
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert is_integer(updated_media_item.duration_seconds)
end
test "it extracts the thumbnail_filepath", %{media_item: media_item} do test "it extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
+52 -26
View File
@@ -38,32 +38,6 @@ defmodule Pinchflat.MediaTest do
end end
end end
describe "list_media_items_for/1" do
test "it returns media_items for a given source" do
source = source_fixture()
media_item = media_item_fixture(%{source_id: source.id})
assert Media.list_media_items_for(source) == [media_item]
end
end
describe "list_media_items_by_media_id_for/2" do
test "returns media_items for a given source and media_ids" do
source = source_fixture()
media_item = media_item_fixture(%{source_id: source.id, media_id: "123"})
assert Media.list_media_items_by_media_id_for(source, ["123"]) == [media_item]
end
test "does not return matching media_ids for a different source" do
source = source_fixture()
other_source = source_fixture()
_media_item = media_item_fixture(%{source_id: other_source.id, media_id: "123"})
assert Media.list_media_items_by_media_id_for(source, ["123"]) == []
end
end
describe "list_pending_media_items_for/1" do describe "list_pending_media_items_for/1" do
test "it returns pending without a filepath for a given source" do test "it returns pending without a filepath for a given source" do
source = source_fixture() source = source_fixture()
@@ -325,6 +299,27 @@ defmodule Pinchflat.MediaTest do
assert Media.pending_download?(media_item) assert Media.pending_download?(media_item)
end end
test "returns true if the content matches the title regex" do
source = source_fixture(%{title_filter_regex: "(?i)^FOO$"})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
assert Media.pending_download?(media_item)
end
test "returns false if the content doesn't match the title regex" do
source = source_fixture(%{title_filter_regex: "(?i)^FOO$"})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "bar"})
refute Media.pending_download?(media_item)
end
test "return true if there is no title regex" do
source = source_fixture(%{title_filter_regex: nil})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
assert Media.pending_download?(media_item)
end
end end
describe "search/1" do describe "search/1" do
@@ -398,6 +393,37 @@ defmodule Pinchflat.MediaTest do
assert media_item.media_filepath == valid_attrs.media_filepath assert media_item.media_filepath == valid_attrs.media_filepath
end end
test "automatically sets the UUID" do
valid_attrs = %{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today()
}
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
assert String.length(media_item.uuid) == 36
end
test "UUID is not writable by the user" do
valid_attrs = %{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today(),
uuid: "some-uuid"
}
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
assert String.length(media_item.uuid) == 36
end
test "creating with invalid data returns error changeset" do test "creating with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Media.create_media_item(@invalid_attrs) assert {:error, %Ecto.Changeset{}} = Media.create_media_item(@invalid_attrs)
end end
@@ -45,6 +45,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
assert result.livestream == metadata["was_live"] assert result.livestream == metadata["was_live"]
end end
test "it extracts the duration in seconds", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert result.duration_seconds == round(metadata["duration"])
end
end end
describe "parse_for_media_item/1 when testing subtitle metadata" do describe "parse_for_media_item/1 when testing subtitle metadata" do
@@ -50,6 +50,23 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
end end
end end
describe "perform/1 when testing attribute updates" do
test "the source description is saved" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture(%{description: nil})
refute source.description
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
assert source.description == "This is a test file for Pinchflat"
end
end
describe "perform/1 when testing metadata storage" do describe "perform/1 when testing metadata storage" do
test "sets metadata location for source" do test "sets metadata location for source" do
stub(YtDlpRunnerMock, :run, fn stub(YtDlpRunnerMock, :run, fn
@@ -0,0 +1,64 @@
defmodule Pinchflat.Podcasts.PodcastHelpersTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
import Pinchflat.MediaFixtures
alias Pinchflat.Podcasts.PodcastHelpers
describe "persisted_media_items_for/2" do
test "returns media items with files that exist on-disk" do
source = source_fixture()
good_media = media_item_with_attachments(%{source_id: source.id})
_bad_media = media_item_fixture(%{source_id: source.id, media_filepath: "/tmp/existing_file.mp3"})
assert [persisted_media] = PodcastHelpers.persisted_media_items_for(source)
assert persisted_media.id == good_media.id
end
test "lets you specify a limit" do
source = source_fixture()
_good_media = media_item_with_attachments(%{source_id: source.id})
assert [] = PodcastHelpers.persisted_media_items_for(source, limit: 0)
end
end
describe "select_cover_image/2" do
test "returns a source's poster, if present" do
source = source_with_metadata_attachments()
{:ok, res} = PodcastHelpers.select_cover_image(source, [])
assert res == source.metadata.poster_filepath
end
test "falls back to a source's fanart, if present" do
source = source_with_metadata_attachments()
File.rm(source.metadata.poster_filepath)
{:ok, res} = PodcastHelpers.select_cover_image(source, [])
assert res == source.metadata.fanart_filepath
end
test "falls back to a media item's thumbnail, if present" do
source = source_with_metadata_attachments()
media_item = media_item_with_metadata_attachments(%{source_id: source.id})
File.rm(source.metadata.poster_filepath)
File.rm(source.metadata.fanart_filepath)
{:ok, res} = PodcastHelpers.select_cover_image(source, [media_item])
assert res == media_item.metadata.thumbnail_filepath
end
test "returns error if no artwork can be found" do
source = source_fixture()
assert PodcastHelpers.select_cover_image(source, []) == {:error, :no_suitable_image}
end
end
end
@@ -0,0 +1,145 @@
defmodule Pinchflat.Podcasts.RssFeedBuilderTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Podcasts.RssFeedBuilder
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
setup do
source = source_fixture()
{:ok, source: source}
end
describe "build/2" do
test "returns an XML document", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<?xml version="1.0" encoding="UTF-8"?>))
end
test "escapes illegal characters" do
source = source_fixture(%{custom_name: "A & B"})
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<title>A &amp; B</title>))
end
test "can optionally apply a limit to media items", %{source: source} do
good_media = media_item_with_attachments(%{source_id: source.id})
res = RssFeedBuilder.build(source, limit: 0)
refute String.contains?(res, ~s(<title>#{good_media.title}</title>))
end
test "can optionally specify a URL base", %{source: source} do
res = RssFeedBuilder.build(source, url_base: "http://example.com")
assert String.contains?(res, ~s(http://example.com/sources/#{source.uuid}/feed.xml))
end
end
describe "build/2 when testing source XML" do
test "returns XML for static source attributes", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<title>#{source.custom_name}</title>))
assert String.contains?(res, ~s(<link>#{source.original_url}</link>))
assert String.contains?(res, ~s(<description>#{source.description}</description>))
assert String.contains?(res, ~s(<itunes:author>#{source.custom_name}</itunes:author>))
assert String.contains?(res, ~s(<itunes:subtitle>#{source.custom_name}</itunes:subtitle>))
assert String.contains?(res, ~s(<description>#{source.description}</description>))
assert String.contains?(res, ~s(<podcast:guid>#{source.uuid}</podcast:guid>))
end
test "returns the lastBuildDate and pubDate based off the source's timestamps", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<lastBuildDate>#{format_date(source.updated_at)}</lastBuildDate>))
assert String.contains?(res, ~s(<pubDate>#{format_date(source.inserted_at)}</pubDate>))
end
test "returns a self-link", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(
res,
~s(<atom:link href="http://localhost:8945/sources/#{source.uuid}/feed.xml" rel="self" type="application/rss+xml" />)
)
end
test "returns a link to the feed image" do
source = source_with_metadata_attachments()
res = RssFeedBuilder.build(source)
[_before, image_block, _after] = String.split(res, ~r(</?image>))
assert String.contains?(image_block, ~s(<url>http://localhost:8945/sources/#{source.uuid}/feed_image.jpg</url>))
assert String.contains?(image_block, ~s(<title>#{source.custom_name}</title>))
assert String.contains?(image_block, ~s(<link>#{source.original_url}</link>))
assert String.contains?(
res,
~s(<itunes:image href="http://localhost:8945/sources/#{source.uuid}/feed_image.jpg"></itunes:image>)
)
end
end
describe "build/2 when testing media XML" do
test "only includes media persisted to disk", %{source: source} do
good_media = media_item_with_attachments(%{source_id: source.id})
bad_media = media_item_fixture(%{source_id: source.id, media_filepath: "/tmp/existing_file.mp3"})
pending_media = media_item_fixture(%{source_id: source.id, media_filepath: nil})
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<title>#{good_media.title}</title>))
refute String.contains?(res, ~s(<title>#{bad_media.title}</title>))
refute String.contains?(res, ~s(<title>#{pending_media.title}</title>))
end
test "returns XML for static media attributes", %{source: source} do
media_item = media_item_with_attachments(%{source_id: source.id})
res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
assert String.contains?(item_xml, ~s(<guid isPermaLink="false">#{media_item.uuid}</guid>))
assert String.contains?(item_xml, ~s(<title>#{media_item.title}</title>))
assert String.contains?(item_xml, ~s(<link>#{media_item.original_url}</link>))
assert String.contains?(item_xml, ~s(<description>#{media_item.description}</description>))
assert String.contains?(item_xml, ~s(<itunes:author>#{source.custom_name}</itunes:author>))
assert String.contains?(item_xml, ~s(<itunes:subtitle>#{media_item.title}</itunes:subtitle>))
assert String.contains?(item_xml, ~s(<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>))
end
test "returns pubDate based off the media's upload_date", %{source: source} do
media_item_with_attachments(%{source_id: source.id, upload_date: ~D[2020-01-01]})
res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
assert String.contains?(item_xml, ~s(<pubDate>Wed, 01 Jan 2020 00:00:00 +0000</pubDate>))
end
test "returns an enclosure tag with the media's stream URL", %{source: source} do
media_item = media_item_with_attachments(%{source_id: source.id, media_size_bytes: 1234})
res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
assert String.contains?(item_xml, ~s(<enclosure))
assert String.contains?(item_xml, ~s(url="http://localhost:8945/media/#{media_item.uuid}/stream.mp4"))
assert String.contains?(item_xml, ~s(length="1234"))
assert String.contains?(item_xml, ~s(type="video/mp4"))
end
end
defp format_date(date) do
Calendar.strftime(date, @datetime_format)
end
end
+25
View File
@@ -59,6 +59,31 @@ defmodule Pinchflat.SourcesTest do
end end
describe "create_source/2" do describe "create_source/2" do
test "automatically sets the UUID" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Source{} = source} = Sources.create_source(valid_attrs)
assert String.length(source.uuid) == 36
end
test "UUID is not writable by the user" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
uuid: "some_uuid"
}
assert {:ok, %Source{} = source} = Sources.create_source(valid_attrs)
assert String.length(source.uuid) == 36
end
test "creates a source and adds name + ID from runner response for channels" do test "creates a source and adds name + ID from runner response for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/3) expect(YtDlpRunnerMock, :run, &channel_mock/3)
@@ -0,0 +1,14 @@
defmodule Pinchflat.Utils.DatetimeUtilsTest do
use ExUnit.Case, async: true
alias Pinchflat.Utils.DatetimeUtils
describe "date_to_datetime/1" do
test "converts a Date to a DateTime" do
date = ~D[2022-01-01]
datetime = DatetimeUtils.date_to_datetime(date)
assert datetime == ~U[2022-01-01 00:00:00Z]
end
end
end
+24 -1
View File
@@ -104,7 +104,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk", original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk",
livestream: false, livestream: false,
short_form_content: false, short_form_content: false,
upload_date: Date.from_iso8601!("2021-01-01") upload_date: Date.from_iso8601!("2021-01-01"),
duration_seconds: 60
} == Media.response_to_struct(response) } == Media.response_to_struct(response)
end end
@@ -174,5 +175,27 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert %Media{upload_date: nil} = Media.response_to_struct(response) assert %Media{upload_date: nil} = Media.response_to_struct(response)
end end
test "parses the duration" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 60.4,
"upload_date" => "20210101"
}
assert %Media{duration_seconds: 60} = Media.response_to_struct(response)
end
test "doesn't blow up if duration is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => nil,
"upload_date" => "20210101"
}
assert %Media{duration_seconds: nil} = Media.response_to_struct(response)
end
end end
end end
@@ -66,6 +66,147 @@ defmodule PinchflatWeb.MediaItemControllerTest do
end end
end end
describe "streaming media" do
test "returns 404 if the media isn't found", %{conn: conn} do
media_item = media_item_fixture()
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert conn.status == 404
end
test "automatically sets the content type", %{conn: conn} do
media_item = media_item_with_attachments()
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert {"content-type", "video/mp4; charset=utf-8"} in conn.resp_headers
end
test "sets the content length", %{conn: conn} do
media_item = media_item_with_attachments()
filesize = File.stat!(media_item.media_filepath).size
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert {"content-length", to_string(filesize)} in conn.resp_headers
end
end
describe "streaming media when range is valid" do
setup do
media_item = media_item_with_attachments()
%{media_item: media_item}
end
test "sets the correct status and headers", %{conn: conn, media_item: media_item} do
filesize = File.stat!(media_item.media_filepath).size
conn =
conn
|> put_req_header("range", "bytes=0-100")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.status == 206
assert {"content-range", "bytes 0-100/#{filesize}"} in conn.resp_headers
assert {"content-length", "101"} in conn.resp_headers
end
test "streams the specified range", %{conn: conn, media_item: media_item} do
conn =
conn
|> put_req_header("range", "bytes=0-100")
|> get(~p"/media/#{media_item.uuid}/stream")
assert byte_size(conn.resp_body) == 101
end
test "supports range offsets", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
expected = String.slice(contents, 100..200)
conn =
conn
|> put_req_header("range", "bytes=100-200")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == expected
end
test "returns as expected if the requested range is larger than the file", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
filesize = File.stat!(media_item.media_filepath).size
conn =
conn
|> put_req_header("range", "bytes=0-#{filesize * 10}")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == contents
assert {"content-range", "bytes 0-#{filesize - 1}/#{filesize}"} in conn.resp_headers
assert {"content-length", to_string(filesize)} in conn.resp_headers
end
test "supports endless ranges", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
conn =
conn
|> put_req_header("range", "bytes=0-")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == contents
end
test "supports endless ranges with offsets", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
{_, expected} = String.split_at(contents, 100)
conn =
conn
|> put_req_header("range", "bytes=100-")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == expected
end
end
describe "streaming media when range is invalid or not present" do
setup do
media_item = media_item_with_attachments()
%{media_item: media_item}
end
test "sets the correct status and headers", %{conn: conn, media_item: media_item} do
filesize = File.stat!(media_item.media_filepath).size
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert conn.status == 200
assert {"content-length", to_string(filesize)} in conn.resp_headers
end
test "streams the entire file", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == contents
end
test "doesn't blow up if the range header is invalid", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
conn =
conn
|> put_req_header("range", "bytes=-")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.status == 200
assert conn.resp_body == contents
end
end
defp create_media_item(_) do defp create_media_item(_) do
media_item = media_item_fixture() media_item = media_item_fixture()
%{media_item: media_item} %{media_item: media_item}
@@ -0,0 +1,38 @@
defmodule PinchflatWeb.PodcastControllerTest do
use PinchflatWeb.ConnCase
import Pinchflat.SourcesFixtures
describe "rss_feed" do
test "renders the XML document", %{conn: conn} do
source = source_fixture()
conn = get(conn, ~p"/sources/#{source.uuid}/feed" <> ".xml")
assert conn.status == 200
assert {"content-type", "application/rss+xml; charset=utf-8"} in conn.resp_headers
assert {"content-disposition", "inline"} in conn.resp_headers
end
end
describe "feed_image" do
test "returns a feed image if one can be found", %{conn: conn} do
source = source_with_metadata_attachments()
conn = get(conn, ~p"/sources/#{source.uuid}/feed_image" <> ".jpg")
assert conn.status == 200
assert {"content-type", "image/jpeg; charset=utf-8"} in conn.resp_headers
assert conn.resp_body == File.read!(source.metadata.poster_filepath)
end
test "returns 404 if an image cannot be found", %{conn: conn} do
source = source_fixture()
conn = get(conn, ~p"/sources/#{source.uuid}/feed_image" <> ".jpg")
assert conn.status == 404
assert conn.resp_body == "Image not found"
end
end
end
+51
View File
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.RoutingTest do defmodule PinchflatWeb.RoutingTest do
use PinchflatWeb.ConnCase use PinchflatWeb.ConnCase
import Pinchflat.SourcesFixtures
describe "basic_auth plug" do describe "basic_auth plug" do
setup do setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username) old_username = Application.get_env(:pinchflat, :basic_auth_username)
@@ -54,4 +56,53 @@ defmodule PinchflatWeb.RoutingTest do
assert conn.status == 200 assert conn.status == 200
end end
end end
describe "maybe_basic_auth plug" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
old_password = Application.get_env(:pinchflat, :basic_auth_password)
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
source = source_fixture()
on_exit(fn ->
Application.put_env(:pinchflat, :basic_auth_username, old_username)
Application.put_env(:pinchflat, :basic_auth_password, old_password)
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
end)
{:ok, source: source}
end
test "uses basic auth when expose_feed_endpoints is false", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 401
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
end
test "does not use basic auth when expose_feed_endpoints is true", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 200
end
test "does not use basic auth when username/password aren't set", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, nil)
Application.put_env(:pinchflat, :basic_auth_password, nil)
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 200
end
end
end end
+34 -5
View File
@@ -5,6 +5,7 @@ defmodule Pinchflat.MediaFixtures do
""" """
alias Pinchflat.SourcesFixtures alias Pinchflat.SourcesFixtures
alias Pinchflat.Filesystem.FilesystemHelpers
@doc """ @doc """
Generate a media_item. Generate a media_item.
@@ -44,18 +45,36 @@ defmodule Pinchflat.MediaFixtures do
media_item_fixture(merged_attrs) media_item_fixture(merged_attrs)
end end
def media_item_with_metadata_attachments(attrs \\ %{}) do
metadata_dir =
Path.join(Application.get_env(:pinchflat, :metadata_directory), "#{:rand.uniform(1_000_000)}")
json_gz_filepath = Path.join(metadata_dir, "metadata.json.gz")
thumbnail_filepath = Path.join(metadata_dir, "thumbnail.jpg")
FilesystemHelpers.cp_p!(media_metadata_filepath_fixture(), json_gz_filepath)
FilesystemHelpers.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath)
merged_attrs =
Map.merge(attrs, %{
metadata: %{
metadata_filepath: json_gz_filepath,
thumbnail_filepath: thumbnail_filepath
}
})
media_item_with_attachments(merged_attrs)
end
def media_item_with_attachments(attrs \\ %{}) do def media_item_with_attachments(attrs \\ %{}) do
stored_media_filepath = stored_media_filepath =
Path.join([ Path.join([
Application.get_env(:pinchflat, :media_directory), Application.get_env(:pinchflat, :media_directory),
"#{:rand.uniform(1_000_000)}", "#{:rand.uniform(1_000_000)}",
"#{:rand.uniform(1_000_000)}_media.mkv" "#{:rand.uniform(1_000_000)}_media.mp4"
]) ])
fixture_media_filepath = media_filepath_fixture() FilesystemHelpers.cp_p!(media_filepath_fixture(), stored_media_filepath)
:ok = File.mkdir_p(Path.dirname(stored_media_filepath))
:ok = File.cp(fixture_media_filepath, stored_media_filepath)
merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath}) merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath})
media_item_fixture(merged_attrs) media_item_fixture(merged_attrs)
@@ -105,4 +124,14 @@ defmodule Pinchflat.MediaFixtures do
"example.info.json" "example.info.json"
]) ])
end end
def media_metadata_filepath_fixture do
Path.join([
File.cwd!(),
"test",
"support",
"files",
"media_metadata.json"
])
end
end end
+28 -1
View File
@@ -5,8 +5,10 @@ defmodule Pinchflat.SourcesFixtures do
""" """
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.ProfilesFixtures alias Pinchflat.MediaFixtures
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.ProfilesFixtures
alias Pinchflat.Filesystem.FilesystemHelpers
@doc """ @doc """
Generate a source. Generate a source.
@@ -22,6 +24,7 @@ defmodule Pinchflat.SourcesFixtures do
collection_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")), collection_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")),
collection_type: "channel", collection_type: "channel",
custom_name: "Cool and good internal name!", custom_name: "Cool and good internal name!",
description: "This is a description",
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
media_profile_id: ProfilesFixtures.media_profile_fixture().id, media_profile_id: ProfilesFixtures.media_profile_fixture().id,
index_frequency_minutes: 60 index_frequency_minutes: 60
@@ -48,6 +51,30 @@ defmodule Pinchflat.SourcesFixtures do
source_fixture(merged_attrs) source_fixture(merged_attrs)
end end
def source_with_metadata_attachments(attrs \\ %{}) do
metadata_dir =
Path.join(Application.get_env(:pinchflat, :metadata_directory), "#{:rand.uniform(1_000_000)}")
json_gz_filepath = Path.join(metadata_dir, "metadata.json.gz")
poster_filepath = Path.join(metadata_dir, "poster.jpg")
fanart_filepath = Path.join(metadata_dir, "fanart.jpg")
FilesystemHelpers.cp_p!(MediaFixtures.media_metadata_filepath_fixture(), json_gz_filepath)
FilesystemHelpers.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), poster_filepath)
FilesystemHelpers.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), fanart_filepath)
merged_attrs =
Map.merge(attrs, %{
metadata: %{
metadata_filepath: json_gz_filepath,
poster_filepath: poster_filepath,
fanart_filepath: fanart_filepath
}
})
source_fixture(merged_attrs)
end
def source_attributes_return_fixture do def source_attributes_return_fixture do
source_attributes = [ source_attributes = [
%{ %{