Compare commits

..

44 Commits

Author SHA1 Message Date
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
Kieran Eglin 64ebf756ab Bumped version 2024-03-25 11:56:31 -07:00
Kieran 377bc617ea Removed unneeded startup tasks (#121) 2024-03-25 11:52:02 -07:00
Kieran a03f69a8d4 Started storing source images in internal metadata folder (#120) 2024-03-25 11:36:03 -07:00
Kieran d39ebe7ac1 Improve default recommendation for artist placeholder (#119)
* Improved default recommendation for audio artist placeholder

* Updated docs
2024-03-25 10:34:22 -07:00
Kieran 8b5dcc15d6 Fixed filepath bug when parsing metadata (#118) 2024-03-25 10:20:29 -07:00
Kieran 5e58dcd879 Cookie auth (#116)
* Added new extras directory

* Automatically create cookie file on boot

* Added cookie file option to DL option builder

* Put cookie auth in correct spot

* Updated Docs

* Removed useless setter for config path
2024-03-24 20:23:25 -07:00
Kieran Eglin a9d5407071 Bumped version 2024-03-24 15:42:03 -07:00
Kieran 37c1da8014 Added GHCR to README (#115) 2024-03-24 15:40:16 -07:00
Kieran Eglin 937a14985f One more GHCR shot 2024-03-24 15:15:43 -07:00
Kieran Eglin a6a20c111f Added GHCR tags 2024-03-24 15:13:12 -07:00
Kieran Eglin 2bb96edc00 Merge branch 'master' of https://github.com/kieraneglin/pinchflat 2024-03-24 15:07:13 -07:00
Kieran Eglin 87f07ff634 Testing GHCR support 2024-03-24 15:07:00 -07:00
Kieran dd60cb5419 Improve preflight file permissions check (#114)
* Added startup permissions check with more helpful message

* Updated README

* Fixes faulty test
2024-03-24 15:06:08 -07:00
Kieran Eglin 610b00566b Added logging for job reset task 2024-03-24 09:28:05 -07:00
Kieran aba15ed886 Adds a method for retrying stuck jobs (#112) 2024-03-24 09:21:01 -07:00
Kieran Eglin 87da716fdf Adds platform option when building docker containers 2024-03-24 09:20:07 -07:00
Kieran Eglin 45f056c6e0 Enabled perf profiler for erlang JIT 2024-03-22 23:34:56 -07:00
Kieran Eglin 11d160008c Updated Docker image generation for GH action 2024-03-22 23:15:51 -07:00
Kieran Eglin ba0a1ba100 Out of alpha, into beta 2024-03-22 12:43:38 -07:00
Kieran Eglin 8c51a286c9 Bumped version 2024-03-21 12:27:14 -07:00
Kieran 46c27e4875 Add docs link (#103)
* Added docs link to README

* Added docs to sidebar in-app

* Updated some tests

* Updated docs in media profile form
2024-03-21 12:26:51 -07:00
Kieran Eglin b639918555 Updated example worker path in README 2024-03-20 17:05:34 -07:00
Kieran Eglin 5596351f58 Renamed 'music' in README to 'audio content' 2024-03-20 16:28:14 -07:00
Kieran ad150b6f16 Improve README (#101)
* Started improving README

* Adds table of contents

* Updated PR template
2024-03-20 16:26:44 -07:00
Kieran Eglin f6d8e15670 Bumped version 2024-03-20 14:48:58 -07:00
Kieran 2906ff1b30 Update logo (#100)
* Placeholder commit

* Added new logo to app

* added logo to header of small screens

* Updated favicon

* Added logo to README

* Updated README
2024-03-20 14:34:49 -07:00
Kieran 0582a7bfd5 Misc indexing improvements (#99)
* increased file follower timeout to 10 minutes

* Removed unique index across a source's collection and its media profile

* Ensure source gets updating during slow indexing
2024-03-20 11:34:33 -07:00
Kieran 3db2e8190f Source title regex filtering (#98)
* Added a basic/advanced mode to source form

* Added regex field and UI to source form

* Implemented title regex filtering
2024-03-20 10:51:14 -07:00
Kieran Eglin 087c9c2d0f Bumped version; added link to donation receipts 2024-03-19 14:32:59 -07:00
Kieran c046abf36f Improve index tables and improved consistency (#97)
* Simplifies index pages

* Improved UX of more pages
2024-03-19 14:03:02 -07:00
Kieran 70dd95211f Download images for sources (#96)
* [WIP] hooked up photo downloading to source metadata worker

* Added new attributes for source images

* Added option to profile form
2024-03-19 13:34:01 -07:00
Kieran 8491f8b317 Source NFO downloads (#95)
* Hooked up series directory finding to source metadata runner

* Fixed aired NFO tag for episodes

* Updated MI NFO builder to take in a filepath

* Hooked up NFO generation to the source worker

* Added NFO controls to form

* Improved the way the source metadata worker updates the source

* Consolidated NFO selection options in media profile instead of source
2024-03-18 14:27:28 -07:00
161 changed files with 14532 additions and 2188 deletions
+2
View File
@@ -13,3 +13,5 @@ N/A
## Any other comments?
N/A
- [ ] I am the original author of this code and I am giving it freely to the community and Pinchflat project maintainers
+23 -1
View File
@@ -4,9 +4,21 @@ on:
workflow_dispatch:
inputs:
image_tag:
type: choice
description: 'Docker Image Tag'
required: true
default: 'dev'
options:
- 'dev'
- 'latest'
platforms:
type: choice
description: 'Build Platforms'
required: true
default: 'linux/amd64,linux/arm64'
options:
- 'linux/amd64,linux/arm64'
- 'linux/amd64'
jobs:
docker:
@@ -24,9 +36,19 @@ jobs:
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 }}'
tags: |
keglin/pinchflat:${{ github.event.inputs.image_tag }}
ghcr.io/${{ github.repository_owner }}/pinchflat:${{ github.event.inputs.image_tag }}
+1 -39
View File
@@ -22,42 +22,4 @@ alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer
defmodule IexHelpers do
def playlist_url do
"https://www.youtube.com/playlist?list=PLmqC3wPkeL8kSlTCcSMDD63gmSi7evcXS"
end
def channel_url do
"https://www.youtube.com/c/TheUselessTrials"
end
def video_url do
"https://www.youtube.com/watch?v=bR52O78ZIUw"
end
def last_media_item do
Repo.one(from m in MediaItem, limit: 1)
end
def details(type) do
source =
case type do
:playlist -> playlist_url()
:channel -> channel_url()
end
YtDlpCollection.get_source_details(source)
end
def ids(type) do
source =
case type do
:playlist -> playlist_url()
:channel -> channel_url()
end
YtDlpCollection.get_media_attributes_for_collection(source)
end
end
import IexHelpers
Pinchflat.Release.check_file_permissions()
+9 -1
View File
@@ -9,7 +9,15 @@
threshold: :low,
# 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
ignore: ["CI.System", "Traversal.FileModule", "Config.HTTPS", "Config.CSP"],
ignore:
[
"CI.System",
"Traversal.FileModule",
"Config.HTTPS",
"Config.CSP",
"XSS.ContentType",
"Traversal.SendFile"
],
ignore_files: [],
version: false
]
+99 -21
View File
@@ -1,39 +1,117 @@
# Pinchflat (Alpha)
<p align="center">
<img
src="priv/static/images/originals/logo-white-wordmark-with-background.png"
alt="Pinchflat Logo by @hernandito"
width="700"
/>
</p>
This is alpha 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.
<p align="center">
<sup>
<em>logo by <a href="https://github.com/hernandito" target="_blank">@hernandito</a></em>
</sup>
</p>
## What is Pinchflat?
# Your next YouTube media manager
TODO: expand on this.
## Table of contents:
Pinchflat is a lightweight self-contained app for downloading YouTube content. For now, it is not intended as a way to consume content, but instead as a way to download content to disk using specified rules and schedules.
- [What it does](#what-it-does)
- [Features](#features)
- [Screenshots](#screenshots)
- [Installation](#installation)
- [Unraid](#unraid)
- [Docker](#docker)
- [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password)
- [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions)
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
- [EFF donations](#eff-donations)
- [Pre-release disclaimer](#pre-release-disclaimer)
- [Development and Contributing](https://github.com/kieraneglin/pinchflat/wiki/Development-and-Contributing)
I have plans for more to come, but for now this is the focus. Think of Pinchflat as nothing more than an automated way to get content from YouTube to your disk.
## What it does
Pinchflat is a self-hosted app for downloading YouTube content built using [yt-dlp](https://github.com/yt-dlp/yt-dlp). It's designed to be lightweight, self-contained, and easy to use. You set up rules for how to download content from YouTube channels or playlists and it'll do the rest, checking periodically for new content. It's perfect for people who want to download content for use in with a media center app (Plex, Jellyfin, Kodi) or for those who want to archive media!
It's _not_ great for downloading one-off videos - it's built to download large amounts of content and keep it up to date. It's also not meant for consuming content in-app - Pinchflat downloads content to disk where you can then watch it with a media center app or VLC.
If it doesn't work for your use case, please make a feature request! You can also check out these great alternatives: [Tube Archivist](https://github.com/tubearchivist/tubearchivist), [ytdl-sub](https://github.com/jmbannon/ytdl-sub), and [TubeSync](https://github.com/meeb/tubesync)
## Features
- Self-contained - just one Docker container with no external dependencies
- 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
- 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
- Uses a novel approach to download new content more quickly than other apps
- Supports downloading audio content
- Custom rules for handling YouTube Shorts and livestreams
- Advanced options like setting cutoff dates and filtering by title
- Reliable hands-off operation
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
## Screenshots
<img src="priv/static/images/app-form-screenshot.png" alt="Pinchflat screenshot" width="700" />
<img src="priv/static/images/app-screenshot.png" alt="Pinchflat screenshot" width="700" />
## Installation
Pinchflat is designed to be self-hosted. I'm building it for my own needs which means it's designed to work well with Unraid, but it should work on any computer/server that can run Docker images.
### Unraid
I'll update with Unraid instructions once I get something in the Community Apps store. Until then, here's how you build it with Docker:
Simply search for Pinchflat in the Community Apps store!
```bash
docker build . --file selfhosted.Dockerfile -t pinchflat:dev
### Portainer
docker run \
-p 8945:8945 \
-v /Users/work/Desktop/test_volumes/config:/config \
-v /Users/work/Desktop/test_volumes/downloads:/downloads \
pinchflat:dev
Docker Compose file:
```yaml
version: '3'
services:
pinchflat:
image: keglin/pinchflat:latest
ports:
- '8945:8945'
volumes:
- /host/path/to/config:/config
- /host/path/to/downloads:/downloads
```
## Authentication
### Docker
Currently HTTP basic auth is optionally supported. To use it, set the `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables when starting the container. If you don't set both of these, no authentication will be required.
1. Create two directories on your host machine: one for storing config and one for storing downloaded media. Make sure they're both writable by the user running the Docker container.
2. Prepare the docker image in one of the two ways below:
- **From GHCR:** `docker pull ghcr.io/kieraneglin/pinchflat:latest`
- NOTE: also available on Docker Hub at `keglin/pinchflat:latest`
- **Building locally:** `docker build . --file selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest`
3. Run the container:
```bash
# Be sure to replace /host/path/to/config and /host/path/to/downloads below with
# the paths to the directories you created in step 1
docker run \
-p 8945:8945 \
-v /host/path/to/config:/config \
-v /host/path/to/downloads:/downloads \
ghcr.io/kieraneglin/pinchflat:latest
```
### IMPORTANT: File permissions
You _must_ ensure the host directories you've mounted are writable by the user running the Docker container. If you get a permission error follow the steps it suggests. See [#106](https://github.com/kieraneglin/pinchflat/issues/106) for more.
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.
## EFF donations
A portion of all donations to Pinchflat will be donated to the [Electronic Frontier Foundation](https://www.eff.org/). The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts.
## Pre-release disclaimer
This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now.
## License
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
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300))
+4 -2
View File
@@ -16,11 +16,13 @@ config :pinchflat,
media_directory: "/downloads",
# The user may or may not store metadata for their needs, but the app will always store its copy
metadata_directory: "/config/metadata",
extras_directory: "/config/extras",
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
# 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.
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"),
basic_auth_username: "",
basic_auth_password: "",
expose_feed_endpoints: false,
file_watcher_poll_interval: 1000
# Configures the endpoint
+1
View File
@@ -3,6 +3,7 @@ import Config
config :pinchflat,
media_directory: Path.join([File.cwd!(), "tmp", "media"]),
metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"]),
extras_directory: Path.join([File.cwd!(), "tmp", "extras"]),
tmpfile_directory: Path.join([File.cwd!(), "tmp", "tmpfiles"])
# Configure your database
+26 -59
View File
@@ -25,17 +25,30 @@ config :pinchflat,
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
if config_env() == :prod do
config_path =
System.get_env("CONFIG_PATH") ||
raise """
environment variable CONFIG_PATH is missing.
For example: /etc/pinchflat/config
"""
arch_string = to_string(:erlang.system_info(:system_architecture))
system_arch =
cond do
String.contains?(arch_string, "arm") -> "arm"
String.contains?(arch_string, "aarch") -> "arm"
String.contains?(arch_string, "x86") -> "x86"
true -> "unknown"
end
config :pinchflat, Pinchflat.Repo,
load_extensions: [
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
]
if config_env() == :prod do
config_path = "/config"
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
# For running PF as a podcast host on self-hosted environments
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
# We want to force _some_ level of useful logging in production
acceptable_log_levels = ~w(debug info)a
@@ -50,8 +63,12 @@ if config_env() == :prod do
config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"),
media_directory: "/downloads",
metadata_directory: metadata_path,
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY")
extras_directory: extras_path,
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints
config :pinchflat, Pinchflat.Repo,
database: db_path,
@@ -89,7 +106,7 @@ if config_env() == :prod do
# 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
# 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")
],
secret_key_base: secret_key_base
@@ -108,54 +125,4 @@ if config_env() == :prod do
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
+2 -15
View File
@@ -18,7 +18,6 @@ defmodule Pinchflat.Boot.DataBackfillWorker do
alias __MODULE__
alias Pinchflat.Repo
alias Pinchflat.Media.MediaItem
@doc """
Cancels all pending backfill jobs. Useful for ensuring worker runs immediately
@@ -44,26 +43,14 @@ defmodule Pinchflat.Boot.DataBackfillWorker do
"""
def perform(%Oban.Job{}) do
Logger.info("Running data backfill worker")
backfill_shorts_data()
# Nothing to do for now - just reschedule
# Keeping in-place because we _will_ need it in the future
reschedule_backfill()
:ok
end
defp backfill_shorts_data do
query =
from(
m in MediaItem,
where: fragment("? like ?", m.original_url, "%/shorts/%"),
where: m.short_form_content == false
)
{count, _} = Repo.update_all(query, set: [short_form_content: true])
Logger.info("Backfill worker set short_form_content to true for #{count} media items.")
end
defp reschedule_backfill do
# Run hourly
next_run_in = 60 * 60
+25 -46
View File
@@ -31,59 +31,38 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
"""
@impl true
def init(state) do
reset_executing_jobs()
create_blank_cookie_file()
apply_default_settings()
ensure_directories_are_writeable()
rename_old_job_workers()
{:ok, state}
end
# If a node cannot gracefully shut down, the currently executing jobs get stuck
# in the "executing" state. This is a problem because the job runner will not
# pick them up again
defp reset_executing_jobs do
{count, _} =
Oban.Job
|> where(state: "executing")
|> Repo.update_all(set: [state: "retryable"])
Logger.info("Reset #{count} executing jobs")
end
defp create_blank_cookie_file do
base_dir = Application.get_env(:pinchflat, :extras_directory)
filepath = Path.join(base_dir, "cookies.txt")
if !File.exists?(filepath) do
Logger.info("Cookies does not exist - creating it")
FilesystemHelpers.write_p!(filepath, "")
end
end
defp apply_default_settings do
Settings.fetch!(:onboarding, true)
Settings.fetch!(:pro_enabled, false)
end
defp ensure_directories_are_writeable do
directories = [
Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :metadata_directory)
]
Enum.each(directories, fn dir ->
file = Path.join([dir, ".keep"])
# This will fail if the directory is not writeable, stopping boot
FilesystemHelpers.write_p!(file, "")
end)
end
# As part of a large refactor, I ended up moving a bunch of workers around. This
# is a problem because the workers are stored in the database and the runner
# will try to run the OLD jobs. This is also why these tasks run before the job
# runner starts up.
#
# Can be removed after a few months (created: 2024-03-12)
defp rename_old_job_workers do
# [ [old_name, new_name], ...]
rename_map = [
["Pinchflat.Workers.MediaIndexingWorker", "Pinchflat.FastIndexing.MediaIndexingWorker"],
["Pinchflat.Workers.MediaDownloadWorker", "Pinchflat.Downloading.MediaDownloadWorker"],
["Pinchflat.Workers.FastIndexingWorker", "Pinchflat.FastIndexing.FastIndexingWorker"],
["Pinchflat.Workers.MediaCollectionIndexingWorker", "Pinchflat.SlowIndexing.MediaCollectionIndexingWorker"],
["Pinchflat.Workers.DataBackfillWorker", "Pinchflat.Boot.DataBackfillWorker"]
]
jobs_renamed =
Enum.reduce(rename_map, 0, fn [old_name, new_name], acc ->
{count, _} =
Oban.Job
|> where(worker: ^old_name)
|> Repo.update_all(set: [worker: new_name])
acc + count
end)
Logger.info("Renamed #{jobs_renamed} old job workers")
end
end
@@ -3,6 +3,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
Builds the options for yt-dlp to download media based on the given media profile.
"""
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder
@@ -26,6 +27,18 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
{:ok, built_options}
end
@doc """
Builds the output path for yt-dlp to download media based on the given source's
media profile.
Returns binary()
"""
def build_output_path_for(%Source{} = source_with_preloads) do
output_path_template = source_with_preloads.media_profile.output_path_template
build_output_path(output_path_template, source_with_preloads)
end
defp default_options do
[:no_progress, :windows_filenames]
end
@@ -104,23 +117,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end
defp output_options(media_item_with_preloads) do
output_path_template = media_item_with_preloads.source.media_profile.output_path_template
[
output: build_output_path(output_path_template, media_item_with_preloads)
output: build_output_path_for(media_item_with_preloads.source)
]
end
defp build_output_path(string, media_item_with_preloads) do
additional_options_map = output_options_map(media_item_with_preloads)
defp build_output_path(string, source) do
additional_options_map = output_options_map(source)
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
Path.join(base_directory(), output_path)
end
defp output_options_map(media_item_with_preloads) do
source = media_item_with_preloads.source
defp output_options_map(source) do
%{
"source_custom_name" => source.custom_name,
"source_collection_type" => source.collection_type
@@ -137,7 +146,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
|> build_output_path(media_item_with_preloads)
|> build_output_path(media_item_with_preloads.source)
end
defp base_directory do
@@ -56,7 +56,9 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp determine_nfo_filepath(media_item, parsed_json) do
if media_item.source.media_profile.download_nfo do
NfoBuilder.build_and_store_for_media_item(parsed_json)
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
NfoBuilder.build_and_store_for_media_item(filepath, parsed_json)
else
nil
end
@@ -42,7 +42,8 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
"upload_day" => "%(upload_date>%d)S",
"upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S",
"season_from_date" => "%(upload_date>%Y)S",
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S"
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S",
"artist_name" => "%(artist,creator,uploader,uploader_id)S"
}
end
end
+26 -2
View File
@@ -20,6 +20,21 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
filepath
end
@doc """
Writes content to a file, creating directories as needed.
Takes the same args as File.write/3.
Returns :ok | {:error, any()}
"""
def write_p(file, content, modes \\ []) do
dirname = Path.dirname(file)
case File.mkdir_p(dirname) do
:ok -> File.write(file, content, modes)
err -> err
end
end
@doc """
Writes content to a file, creating directories as needed.
Takes the same args as File.write!/3.
@@ -27,11 +42,20 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
Returns :ok | raises on error
"""
def write_p!(filepath, content, modes \\ []) do
filepath
:ok = write_p(filepath, content, modes)
end
@doc """
Copies a file from source to destination, creating directories as needed.
Returns :ok | raises on error
"""
def cp_p!(source, destination) do
destination
|> Path.dirname()
|> File.mkdir_p!()
File.write!(filepath, content, modes)
File.cp!(source, destination)
end
@doc """
+10
View File
@@ -66,6 +66,7 @@ defmodule Pinchflat.Media do
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile))
|> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
|> Repo.maybe_limit(limit)
|> Repo.all()
end
@@ -100,6 +101,7 @@ defmodule Pinchflat.Media do
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_item.source.media_profile))
|> where(^maybe_apply_cutoff_date(media_item.source))
|> where(^maybe_apply_title_regex(media_item.source))
|> Repo.exists?()
end
@@ -247,6 +249,14 @@ defmodule Pinchflat.Media do
end
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)
+8
View File
@@ -5,6 +5,7 @@ defmodule Pinchflat.Media.MediaItem do
use Ecto.Schema
import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils
alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source
@@ -32,6 +33,7 @@ defmodule Pinchflat.Media.MediaItem do
]
# Pretty much all the fields captured at index are required.
@required_fields ~w(
uuid
title
original_url
livestream
@@ -42,6 +44,11 @@ defmodule Pinchflat.Media.MediaItem do
)a
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 :media_id, :string
field :description, :string
@@ -78,6 +85,7 @@ defmodule Pinchflat.Media.MediaItem do
media_item
|> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(@required_fields)
|> unique_constraint([:media_id, :source_id])
end
@@ -11,6 +11,22 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
alias Pinchflat.Filesystem.FilesystemHelpers
@doc """
Returns the directory where metadata for a database record should be stored.
Returns binary()
"""
def metadata_directory_for(database_record) do
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
record_table_name = database_record.__meta__.source
Path.join([
metadata_directory,
record_table_name,
to_string(database_record.id)
])
end
@doc """
Compresses and stores metadata for a media item, returning the filepath.
@@ -63,6 +79,43 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
Date.from_iso8601!("#{year}-#{month}-#{day}")
end
@doc """
Attempts to determine the series directory from a media filepath.
The series directory is the "root" directory for a given source
which should contain all the season-level folders of that source.
Used for determining where to store things like NFO data and banners
for media center apps. Not useful without a media center app.
Returns {:ok, binary()} | {:error, :indeterminable}
"""
def series_directory_from_media_filepath(media_filepath) do
# Matches "s" or "season" (case-insensitive)
# followed by an optional non-word character (. or _ or <space>, etc)
# followed by at least one digit
# followed immediately by the end of the string
# Example matches: s1, s.1, s01 season 1, Season.01, Season_1, Season 1, Season1
# Example non-matches: s01e01, season, series 1,
season_regex = ~r/^s(eason)?(\W|_)?\d{1,}$/i
{series_directory, found_series_directory} =
media_filepath
|> Path.split()
|> Enum.reduce_while({[], false}, fn part, {directory_acc, _} ->
if String.match?(part, season_regex) do
{:halt, {directory_acc, true}}
else
{:cont, {directory_acc ++ [part], false}}
end
end)
if found_series_directory do
{:ok, Path.join(series_directory)}
else
{:error, :indeterminable}
end
end
defp fetch_thumbnail_from_url(url) do
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
{:ok, body} = http_client.get(url, [], body_format: :binary)
@@ -71,13 +124,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
end
defp generate_filepath_for(database_record, filename) do
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
record_table_name = database_record.__meta__.source
Path.join([
metadata_directory,
record_table_name,
to_string(database_record.id),
metadata_directory_for(database_record),
filename
])
end
+22 -7
View File
@@ -58,23 +58,38 @@ defmodule Pinchflat.Metadata.MetadataParser do
# NOTE: whole ordeal needed due to a bug I found in yt-dlp
# https://github.com/yt-dlp/yt-dlp/issues/9445
# Can be reverted to remove this entire conditional once fixed
filepath =
thumbnail_filepath
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
%{
thumbnail_filepath:
thumbnail_filepath
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
thumbnail_filepath: filepath_if_exists(filepath)
}
else
%{
thumbnail_filepath: thumbnail_filepath
thumbnail_filepath: nil
}
end
end
defp parse_infojson_metadata(metadata) do
%{
metadata_filepath: metadata["infojson_filename"]
metadata_filepath: filepath_if_exists(metadata["infojson_filename"])
}
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(filepath) do
if File.exists?(filepath) do
filepath
else
nil
end
end
end
+29 -5
View File
@@ -9,13 +9,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
@doc """
Builds an NFO file for a media item (read: single "episode") and
stores it in the same directory as the media file. Has the same name
as the media file, but with a .nfo extension.
stores it at the specified location.
Returns the filepath of the NFO file.
"""
def build_and_store_for_media_item(metadata) do
filepath = Path.rootname(metadata["filepath"]) <> ".nfo"
def build_and_store_for_media_item(filepath, metadata) do
nfo = build_for_media_item(metadata)
FilesystemHelpers.write_p!(filepath, nfo)
@@ -23,6 +21,20 @@ defmodule Pinchflat.Metadata.NfoBuilder do
filepath
end
@doc """
Builds an NFO file for a souce and stores it at the specified location.
Technically works for playlists, but it's really made for channels.
Returns the filepath of the NFO file.
"""
def build_and_store_for_source(filepath, metadata) do
nfo = build_for_source(metadata)
FilesystemHelpers.write_p!(filepath, nfo)
filepath
end
defp build_for_media_item(metadata) do
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
@@ -34,11 +46,23 @@ defmodule Pinchflat.Metadata.NfoBuilder do
<showtitle>#{metadata["uploader"]}</showtitle>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<plot>#{metadata["description"]}</plot>
<premiered>#{upload_date}</premiered>
<aired>#{upload_date}</aired>
<season>#{upload_date.year}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<genre>YouTube</genre>
</episodedetails>
"""
end
defp build_for_source(metadata) do
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<tvshow>
<title>#{metadata["title"]}</title>
<plot>#{metadata["description"]}</plot>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<genre>YouTube</genre>
</tvshow>
"""
end
end
@@ -0,0 +1,69 @@
defmodule Pinchflat.Metadata.SourceImageParser do
@moduledoc """
Functions for parsing and storing source images.
"""
alias Pinchflat.Filesystem.FilesystemHelpers
@doc """
Given a base directory and source metadata, look for the appropriate images
and move them to the base directory. Returns a map of image types and their
filepaths (specifically for a source). If a given image type is not found,
the key will not be present in the map.
The metadata is expected to contain the `filepath` key for images, implying
that the images have already been downloaded by yt-dlp. This method will NOT
download images from the internet - it just copys them around.
Returns a map with the possible keys :poster_filepath, :fanart_filepath, and
:banner_filepath.
"""
def store_source_images(base_directory, source_metadata) do
(source_metadata["thumbnails"] || [])
|> Enum.filter(&(&1["filepath"] != nil))
|> select_useful_images()
|> Enum.map(&move_image(&1, base_directory))
|> Enum.into(%{})
end
defp select_useful_images(images) do
labelled_images =
Enum.reduce(images, [], fn image_map, acc ->
case image_map do
%{"id" => "avatar_uncropped"} ->
acc ++ [{:poster, :poster_filepath, image_map["filepath"]}]
%{"id" => "banner_uncropped"} ->
acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}]
_ ->
acc
end
end)
labelled_images
|> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}])
|> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end)
end
defp determine_best_banner(images) do
best_candidate =
images
# Filter out images that don't have a width and height attribute
|> Enum.filter(&(&1["width"] && &1["height"]))
# Sort images with the highest width first
|> Enum.sort(&(&1["width"] >= &2["width"]))
# Find the first image where the ratio of width to height is greater than 3
|> Enum.find(&(&1["width"] / &1["height"] > 3))
Map.get(best_candidate || %{}, "filepath")
end
defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do
extension = Path.extname(tmp_filepath)
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
FilesystemHelpers.cp_p!(tmp_filepath, final_filepath)
{source_attr_name, final_filepath}
end
end
+16 -2
View File
@@ -10,11 +10,20 @@ defmodule Pinchflat.Metadata.SourceMetadata do
alias Pinchflat.Sources.Source
@allowed_fields ~w(metadata_filepath)a
@allowed_fields ~w(
metadata_filepath
fanart_filepath
poster_filepath
banner_filepath
)a
@required_fields ~w(metadata_filepath)a
schema "source_metadata" do
field :metadata_filepath, :string
field :fanart_filepath, :string
field :poster_filepath, :string
field :banner_filepath, :string
belongs_to :source, Source
@@ -31,6 +40,11 @@ defmodule Pinchflat.Metadata.SourceMetadata do
@doc false
def filepath_attributes do
~w(metadata_filepath)a
~w(
metadata_filepath
fanart_filepath
poster_filepath
banner_filepath
)a
end
end
@@ -4,10 +4,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
use Oban.Worker,
queue: :remote_metadata,
tags: ["media_source", "source_metadata", "remote_metadata"],
max_attempts: 1,
# This is the only thing stopping this job from calling itself
# in an infinite loop.
unique: [period: 600]
max_attempts: 3
require Logger
@@ -15,8 +12,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Utils.StringUtils
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceImageParser
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Downloading.DownloadOptionBuilder
@doc """
Starts the source metadata storage worker and creates a task for the source.
@@ -30,27 +31,86 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
end
@doc """
Fetches and stores metadata for a source in the secret metadata location.
Fetches and stores various forms of metadata for a source:
- Attributes like `description`
- JSON metadata for internal use
- The series directory for the source
- The NFO file for the source (if specified)
- Downloads and stores source images (if specified)
The worker is kicked off after a source is inserted/updated - this can
take an unknown amount of time so don't rely on this data being here
before, say, the first indexing or downloading task is complete.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id}}) do
source = Repo.preload(Sources.get_source!(source_id), :metadata)
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url)
source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile])
series_directory = determine_series_directory(source)
# Since updating a source kicks this job off again, we enforce job uniqueness (above)
# to once, per source, per x minutes. This is to prevent a job from calling itself
# in an infinite loop.
Sources.update_source(source, %{
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, metadata)
}
})
{source_metadata, source_image_attrs, metadata_image_attrs} =
fetch_source_metadata_and_images(series_directory, source)
source_metadata_filepath = MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata)
Sources.update_source(
source,
Map.merge(
%{
series_directory: series_directory,
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)
},
source_image_attrs
),
# `run_post_commit_tasks: false` prevents this from running in an infinite loop
run_post_commit_tasks: false
)
:ok
rescue
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end
defp fetch_source_metadata_and_images(series_directory, source) do
metadata_directory = MetadataFileHelpers.metadata_directory_for(source)
tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: tmp_output_path]
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
metadata_image_attrs = SourceImageParser.store_source_images(metadata_directory, metadata)
if source.media_profile.download_source_images && series_directory do
source_image_attrs = SourceImageParser.store_source_images(series_directory, metadata)
{metadata, source_image_attrs, metadata_image_attrs}
else
{metadata, %{}, metadata_image_attrs}
end
end
defp determine_series_directory(source) do
output_path = DownloadOptionBuilder.build_output_path_for(source)
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, output: output_path)
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
{:ok, series_directory} -> series_directory
{:error, _} -> nil
end
end
defp store_source_nfo(source, series_directory, metadata) do
if source.media_profile.download_nfo && series_directory do
nfo_filepath = Path.join(series_directory, "tvshow.nfo")
NfoBuilder.build_and_store_for_source(nfo_filepath, metadata)
end
end
defp tmp_directory do
Application.get_env(:pinchflat, :tmpfile_directory)
end
end
+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
+138
View File
@@ -0,0 +1,138 @@
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>
<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
+2
View File
@@ -17,6 +17,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
sub_langs
download_thumbnail
embed_thumbnail
download_source_images
download_metadata
embed_metadata
download_nfo
@@ -40,6 +41,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :download_thumbnail, :boolean, default: false
field :embed_thumbnail, :boolean, default: false
field :download_source_images, :boolean, default: false
field :download_metadata, :boolean, default: false
field :embed_metadata, :boolean, default: false
+62
View File
@@ -5,6 +5,10 @@ defmodule Pinchflat.Release do
"""
@app :pinchflat
require Logger
alias Pinchflat.Filesystem.FilesystemHelpers
def migrate do
load_app()
@@ -18,6 +22,37 @@ defmodule Pinchflat.Release do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
def check_file_permissions do
load_app()
directories = [
"/config",
"/downloads",
Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :extras_directory),
Application.get_env(:pinchflat, :metadata_directory)
]
Enum.each(directories, fn dir ->
Logger.info("Checking permissions for #{dir}")
filepath = Path.join([dir, ".keep"])
case FilesystemHelpers.write_p(filepath, "") do
:ok ->
Logger.info("Permissions OK")
{:error, :eacces} ->
Logger.error(permission_denied_screed(dir))
raise "Permission denied"
err ->
Logger.error("Permissions check failed: #{inspect(err)}")
raise "Unknown error"
end
end)
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
@@ -25,4 +60,31 @@ defmodule Pinchflat.Release do
defp load_app do
Application.load(@app)
end
defp permission_denied_screed(dir) do
"""
The directory "#{dir}" is not writeable by the Docker container.
Please ensure that the directory exists and is writeable by the Docker
container. All setups are different, but you may be able to run something
like this on the *host*:
chown nobody -R <host path that maps to #{dir}>
chmod 755 -R <host path that maps to #{dir}>
Swapping in your real host path. Then, you should set the user running
this container by editing your `docker run` command like so:
docker run --user 99:100 <rest of the command>
Or adding `user: '99:100'` to the Pinchflat service of your Docker Compose
file. Again, there are many ways to do this depending on your setup and
this is just one example. See issue #106 in the Pinchflat Github for more.
No matter the case, this _is_ a permissions error and allowing the container
to write to the directory is the only way to fix it. It is not recommended
to run the container as `root` because files created by Pinchflat may not
be accessible to other apps that want to modify them.
"""
end
end
@@ -9,7 +9,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
require Logger
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
@activity_timeout_ms 60_000
@activity_timeout_ms 600_000
# Client API
@doc """
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources
@@ -60,6 +61,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it)
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
result =
Enum.map(media_attributes, fn media_attrs ->
@@ -117,6 +121,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
end
defp create_media_item_and_enqueue_download(source, media_attrs) do
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
{:ok, %MediaItem{} = media_item} ->
if source.download_media && Media.pending_download?(media_item) do
+27 -1
View File
@@ -17,12 +17,19 @@ defmodule Pinchflat.Sources.Source do
collection_id
collection_type
custom_name
description
nfo_filepath
poster_filepath
fanart_filepath
banner_filepath
series_directory
index_frequency_minutes
fast_index
download_media
last_indexed_at
original_url
download_cutoff_date
title_filter_regex
media_profile_id
)a
@@ -41,6 +48,7 @@ defmodule Pinchflat.Sources.Source do
@pre_insert_required_fields @initially_required_fields ++
~w(
uuid
custom_name
collection_name
collection_id
@@ -48,7 +56,13 @@ defmodule Pinchflat.Sources.Source do
)a
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 :description, :string
field :collection_name, :string
field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
@@ -59,6 +73,13 @@ defmodule Pinchflat.Sources.Source do
# Only download media items that were published after this date
field :download_cutoff_date, :date
field :original_url, :string
field :title_filter_regex, :string
field :series_directory, :string
field :nfo_filepath, :string
field :poster_filepath, :string
field :fanart_filepath, :string
field :banner_filepath, :string
belongs_to :media_profile, MediaProfile
@@ -83,9 +104,9 @@ defmodule Pinchflat.Sources.Source do
source
|> cast(attrs, @allowed_fields)
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id])
end
@doc false
@@ -99,4 +120,9 @@ defmodule Pinchflat.Sources.Source do
# minutes
15
end
@doc false
def filepath_attributes do
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
end
end
+40 -20
View File
@@ -51,15 +51,19 @@ defmodule Pinchflat.Sources do
though we know it's going to fail so it picks up any addl. database errors
and fulfills our return contract.
You can pass options to control the behavior of the function:
- `run_post_commit_tasks` (default: true) - If false, the function will not
enqueue any tasks in `commit_and_handle_tasks`.
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
"""
def create_source(attrs) do
def create_source(attrs, opts \\ []) do
case change_source(%Source{}, attrs, :initial) do
%Ecto.Changeset{valid?: true} ->
%Source{}
|> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency()
|> commit_and_handle_tasks()
|> commit_and_handle_tasks(opts)
changeset ->
Repo.insert(changeset)
@@ -79,15 +83,19 @@ defmodule Pinchflat.Sources do
though we know it's going to fail so it picks up any addl. database errors
and fulfills our return contract.
You can pass options to control the behavior of the function:
- `run_post_commit_tasks` (default: true) - If false, the function will not
enqueue any tasks in `commit_and_handle_tasks`.
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
"""
def update_source(%Source{} = source, attrs) do
def update_source(%Source{} = source, attrs, opts \\ []) do
case change_source(source, attrs, :initial) do
%Ecto.Changeset{valid?: true} ->
source
|> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency()
|> commit_and_handle_tasks()
|> commit_and_handle_tasks(opts)
changeset ->
Repo.update(changeset)
@@ -102,7 +110,6 @@ defmodule Pinchflat.Sources do
"""
def delete_source(%Source{} = source, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false)
Tasks.delete_tasks_for(source)
source
@@ -111,7 +118,11 @@ defmodule Pinchflat.Sources do
Media.delete_media_item(media_item, delete_files: delete_files)
end)
delete_source_metadata_files(source)
if delete_files do
delete_source_files(source)
end
delete_internal_metadata_files(source)
Repo.delete(source)
end
@@ -134,22 +145,27 @@ defmodule Pinchflat.Sources do
end
end
defp delete_source_metadata_files(source) do
defp delete_source_files(source) do
mapped_struct = Map.from_struct(source)
Source.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end
defp delete_internal_metadata_files(source) do
metadata = Repo.preload(source, :metadata).metadata || %SourceMetadata{}
mapped_struct = Map.from_struct(metadata)
filepaths =
SourceMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
Enum.each(filepaths, &FilesystemHelpers.delete_file_and_remove_empty_directories/1)
SourceMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end
defp add_source_details_to_changeset(source, changeset) do
%Ecto.Changeset{changes: changes} = changeset
case MediaCollection.get_source_details(changes.original_url) do
case MediaCollection.get_source_details(changeset.changes.original_url) do
{:ok, source_details} ->
add_source_details_by_collection_type(source, changeset, source_details)
@@ -198,12 +214,16 @@ defmodule Pinchflat.Sources do
end
end
defp commit_and_handle_tasks(changeset) do
defp commit_and_handle_tasks(changeset, opts) do
run_post_commit_tasks = Keyword.get(opts, :run_post_commit_tasks, true)
case Repo.insert_or_update(changeset) do
{:ok, %Source{} = source} ->
maybe_handle_media_tasks(changeset, source)
maybe_run_indexing_task(changeset, source)
run_metadata_storage_task(source)
if run_post_commit_tasks do
maybe_handle_media_tasks(changeset, source)
maybe_run_indexing_task(changeset, source)
run_metadata_storage_task(source)
end
{:ok, source}
+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
+15 -1
View File
@@ -31,7 +31,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
output_filepath = Keyword.get(addl_opts, :output_filepath, FSUtils.generate_metadata_tmpfile(:json))
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts)
cookie_opts = build_cookie_options()
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
@@ -47,6 +48,19 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end
end
defp build_cookie_options do
base_dir = Application.get_env(:pinchflat, :extras_directory)
cookie_file = Path.join(base_dir, "cookies.txt")
case File.read(cookie_file) do
{:ok, cookie_data} ->
if String.trim(cookie_data) != "", do: [cookies: cookie_file], else: []
{:error, _} ->
[]
end
end
# We want to satisfy the following behaviours:
#
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
+11 -7
View File
@@ -64,14 +64,14 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_details(source_url) do
def get_source_details(source_url, addl_opts \\ []) do
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the source details
opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
output_template = "%(.{channel,channel_id,playlist_id,playlist_title})j"
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1] ++ addl_opts
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
with {:ok, output} <- backend_runner().run(source_url, command_opts, output_template),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)}
else
@@ -94,8 +94,8 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_metadata(source_url) do
opts = [playlist_items: 0]
def get_source_metadata(source_url, addl_opts \\ []) do
opts = [playlist_items: 0] ++ addl_opts
output_template = "playlist:%()j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
@@ -112,7 +112,11 @@ defmodule Pinchflat.YtDlp.MediaCollection do
channel_id: response["channel_id"],
channel_name: response["channel"],
playlist_id: response["playlist_id"],
playlist_name: response["playlist_title"]
playlist_name: response["playlist_title"],
# It's not a name, it's a path dammit!
# This actually isn't used for the inital response - it's
# used later to update a source's metadata
filepath: response["filename"]
}
end
+1 -1
View File
@@ -21,7 +21,7 @@ defmodule PinchflatWeb do
def router do
quote do
use Phoenix.Router, helpers: false
use Phoenix.Router, helpers: true
# Import common connection and controller functions to use in pipelines
import Plug.Conn
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
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
@@ -347,8 +347,9 @@ defmodule PinchflatWeb.CoreComponents do
id={@id}
name={@name}
class={[
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent 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",
"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 bg-form-input text-black text-white",
"disabled:text-opacity-50 disabled:cursor-not-allowed disabled:border-black",
@inputclass
]}
multiple={@multiple}
@@ -654,10 +655,11 @@ defmodule PinchflatWeb.CoreComponents do
"""
attr :name, :string, required: true
attr :class, :string, default: nil
attr :rest, :global
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
<span class={[@name, @class]} {@rest} />
"""
end
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.CustomComponents.ButtonComponents do
@moduledoc false
use Phoenix.Component
use Phoenix.Component, global_prefixes: ~w(x-)
alias PinchflatWeb.CoreComponents
@doc """
Render a button
@@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
</button>
"""
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
@@ -9,24 +9,31 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
attr :title, :string, required: true
end
slot :tab_append, required: false
def tabbed_layout(assigns) do
~H"""
<div
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
class="w-full"
>
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10">
<a
:for={{tab, idx} <- Enum.with_index(@tab)}
href="#"
@click.prevent={"openTab = #{idx}"}
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
class="border-b-2 py-4 text-sm font-medium hover:text-meta-5 md:text-base"
>
<span class="text-xl"><%= tab.title %></span>
</a>
</div>
<div>
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
<div class="flex flex-wrap gap-5 sm:gap-10">
<a
:for={{tab, idx} <- Enum.with_index(@tab)}
href="#"
@click.prevent={"openTab = #{idx}"}
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
>
<span class="text-xl"><%= tab.title %></span>
</a>
</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">
<%= render_slot(tab) %>
</div>
@@ -31,6 +31,20 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
"""
end
@doc """
Renders a subtle link with the given href and content.
"""
attr :href, :string, required: true
slot :inner_block
def subtle_link(assigns) do
~H"""
<.link href={@href} class="underline decoration-bodydark decoration-1 hover:decoration-white">
<%= render_slot(@inner_block) %>
</.link>
"""
end
@doc """
Renders an icon as a link with the given href.
"""
@@ -1,10 +1,9 @@
<div class="flex h-screen overflow-hidden">
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<div class="flex flex-grow items-center px-4 py-4 shadow-2 md:px-6 2xl:px-11">
<div class="flex items-center">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
<div class="w-65 px-4 py-2 shadow-2 md:px-6">
<div class="flex items-center gap-2 py-2">
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
</div>
</div>
</header>
@@ -1,18 +1,18 @@
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<header class="sticky top-0 z-999 flex h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<div class="flex flex-grow items-center justify-between lg:justify-end px-4 py-4 shadow-2 md:px-6 2xl:px-11">
<div class="flex items-center gap-2 sm:gap-4 lg:hidden">
<div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
<section class="pr-1">
<img src={~p"/images/icon.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-10" />
</section>
<button
class="z-99999 block rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
class="z-99999 block mx-2 rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
@click.stop="sidebarVisible = !sidebarVisible"
>
<.icon name="hero-bars-3" />
</button>
<a class="hidden sm:flex items-center lg:hidden" href="/">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</a>
</div>
<div class="bg-meta-4 rounded-md">
<div class="bg-meta-4 rounded-md w-4/6 lg:w-3/6 xl:w-2/6">
<div class="relative">
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
<.icon name="hero-magnifying-glass" />
@@ -23,7 +23,7 @@
name="q"
value={@params["q"]}
placeholder="Type to search..."
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none lg:w-125"
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
/>
</form>
</div>
@@ -1,24 +1,23 @@
<aside
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
class={[
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-60 flex-col overflow-y-hidden justify-between",
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-65 flex-col overflow-y-hidden justify-between",
"bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0"
]}
@click.outside="sidebarVisible = false"
>
<section>
<div class="flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5">
<div class="flex items-center justify-between gap-2 px-6 py-4">
<a href="/" class="flex items-center">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
</a>
<button class="block lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
<button class="block mt-3 lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
<.icon name="hero-arrow-left" class="fill-current" />
</button>
</div>
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
<nav class="mt-3 px-4 py-4 lg:px-6">
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
<div class="flex flex-col justify-between">
<ul class="mb-6 flex flex-col gap-1.5">
@@ -31,8 +30,14 @@
</div>
</section>
<section>
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
<nav class="px-4 py-4 lg:px-6">
<ul class="mb-6 flex flex-col gap-1.5">
<.sidebar_item
icon="hero-book-open"
text="Docs"
target="_blank"
href="https://github.com/kieraneglin/pinchflat/wiki"
/>
<.sidebar_item
icon="hero-code-bracket"
text="Github"
@@ -52,6 +57,11 @@
<.icon name="hero-currency-dollar" /> Donate
</span>
</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>
</nav>
</section>
@@ -8,7 +8,7 @@
<%= assigns[:page_title] || "Pinchflat" %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-02-29"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
@@ -1,8 +1,11 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
def show(conn, %{"id" => id}) do
media_item =
@@ -29,4 +32,69 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|> put_flash(:info, flash_message)
|> redirect(to: ~p"/sources/#{media_item.source_id}")
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
@@ -2,4 +2,16 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
use PinchflatWeb, :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
@@ -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>
</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">
<.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">
<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>
<section>
<strong>Source:</strong>
@@ -23,18 +42,6 @@
<.list_items_from_map map={Map.from_struct(@media_item)} />
</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-full">
Delete Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Tasks">
<%= if match?([_|_], @media_item.tasks) do %>
@@ -39,7 +39,8 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
upload_year: nil,
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
source_custom_name: "the name of the sources that use this profile",
source_collection_type: "the collection type of the sources that use this profile. Either 'channel' or 'playlist'"
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
artist_name: "the name of the artist with fallbacks to other uploader fields"
}
end
@@ -74,6 +75,6 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
end
defp audio_output_template do
"/music/{{ source_custom_name }}/{{ title }}.{{ ext }}"
"/music/{{ artist_name }}/{{ title }}.{{ ext }}"
end
end
@@ -2,7 +2,9 @@
<.link href={~p"/media_profiles"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Media Profile</h2>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @media_profile.name %>"
</h2>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
@@ -4,7 +4,7 @@
</h2>
<nav>
<.link href={~p"/media_profiles/new"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold text-xl mx-2">+</span> New <span class="hidden sm:inline pl-1">Media Profile</span>
</.button>
</.link>
@@ -16,10 +16,9 @@
<div class="flex flex-col gap-10 min-w-max">
<.table rows={@media_profiles} table_class="text-black dark:text-white">
<:col :let={media_profile} label="Name">
<%= media_profile.name %>
</:col>
<:col :let={media_profile} label="Output Template">
<code class="text-sm"><%= media_profile.output_path_template %></code>
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
<%= media_profile.name %>
</.subtle_link>
</:col>
<:col :let={media_profile} label="Preferred Resolution">
<%= media_profile.preferred_resolution %>
@@ -38,7 +38,7 @@
presets: {
default: 'Default',
media_center: 'TV Shows',
audio: 'Audio',
audio: 'Music',
archiving: 'Archiving'
}
}">
@@ -162,16 +162,6 @@
/>
</section>
<section x-data="{ presets: { default: false, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_nfo]}
type="toggle"
label="Download NFO data"
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
Release Format Options
</h3>
@@ -181,7 +171,7 @@
field={f[:shorts_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Shorts?"
label="Include Shorts"
help="Experimental. Please report any issues on GitHub"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
@@ -192,7 +182,7 @@
field={f[:livestream_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Livestreams?"
label="Include Livestreams"
help="Excludes media that comes from a past livestream"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
@@ -213,7 +203,42 @@
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Media Center Options
</h3>
<p class="text-sm mt-2 max-w-prose">
Everything in this section is experimental - please open a GitHub issue if you see something odd.
<strong>
These options only work if this Media Profile's output template is set to split media into seasons.
</strong>
Try the "Media Center" preset if you're not sure.
</p>
<section
phx-click={show_modal("upgrade-modal")}
x-data="{ presets: { default: false, media_center: true, audio: false, archiving: false } }"
>
<.input
field={f[:download_nfo]}
type="toggle"
label="Download NFO data"
label_suffix="(pro)"
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: false, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_source_images]}
type="toggle"
label="Download Series Images"
help="Downloads poster and banner images for use with Plex, Jellyfin, Kodi, etc. Only works for full channels (not playlists)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
</section>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
@@ -4,13 +4,13 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
Media Profile <span class="hidden sm:inline">#<%= @media_profile.id %></span>
<%= @media_profile.name %>
</h2>
</div>
<nav>
<.link href={~p"/media_profiles/#{@media_profile}/edit"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<.icon name="hero-pencil-square" class="mr-2" />Edit <span class="hidden sm:inline pl-1">Media Profile</span>
</.button>
</.link>
@@ -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="max-w-full overflow-x-auto">
<.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
href={~p"/media_profiles/#{@media_profile}"}
method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
>
Delete Profile
</.link>
</:option>
<:option>
<.link
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Profile + Files
</.link>
</:option>
</.button_dropdown>
</: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>
<section class="flex flex-col md:flex-row items-center md:justify-around my-10">
<.link
href={~p"/media_profiles/#{@media_profile}"}
method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Profile and its Sources
</.button>
</.link>
<.link
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Profile, Sources, and Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
@@ -11,7 +11,7 @@
<p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p>
<div class="mt-8">
<.link href={~p"/media_profiles/new"}>
<.button color="bg-primary" rounding="rounded-full" disabled={@media_profiles_exist}>
<.button color="bg-primary" rounding="rounded-lg" disabled={@media_profiles_exist}>
<span class="font-bold mx-2">+</span> New Media Profile
</.button>
</.link>
@@ -25,7 +25,7 @@
</p>
<div class="mt-8">
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-full" disabled={not @media_profiles_exist}>
<.button color="bg-primary" rounding="rounded-lg" disabled={not @media_profiles_exist}>
<span class="font-bold mx-2">+</span> New Source
</.button>
</.link>
@@ -40,7 +40,7 @@
<p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p>
<div class="mt-8">
<.link href={~p"/?onboarding=0"}>
<.button color="bg-primary" rounding="rounded-full" disabled={not @sources_exist}>
<.button color="bg-primary" rounding="rounded-lg" disabled={not @sources_exist}>
Let's Go <span class="font-bold mx-2">🚀</span>
</.button>
</.link>
@@ -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,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white">
<span class="hidden sm:inline">Search </span>Results for "<%= StringUtils.truncate(@search_term, 50) %>"
Results for "<%= StringUtils.truncate(@search_term, 50) %>"
</h2>
</div>
@@ -10,7 +10,7 @@
<%= if match?([_|_], @search_results) do %>
<.table rows={@search_results} table_class="text-black dark:text-white">
<:col :let={result} label="Title">
<%= StringUtils.truncate(result.title, 40) %>
<%= result.title %>
</:col>
<:col :let={result} label="Excerpt">
<.highlight_search_terms text={result.matching_search_term} />
@@ -1,12 +1,15 @@
defmodule PinchflatWeb.Sources.SourceController do
use PinchflatWeb, :controller
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Profiles
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
def index(conn, _params) do
sources = Repo.preload(Sources.list_sources(), :media_profile)
@@ -54,7 +57,8 @@ defmodule PinchflatWeb.Sources.SourceController do
source: source,
pending_tasks: pending_tasks,
pending_media: pending_media,
downloaded_media: downloaded_media
downloaded_media: downloaded_media,
total_downloaded: total_downloaded_for(source)
)
end
@@ -104,6 +108,17 @@ defmodule PinchflatWeb.Sources.SourceController do
Profiles.list_media_profiles()
end
# NOTE: should move this out of the controller
# once I finally add some query fragment layer
defp total_downloaded_for(source) do
from(
m in MediaItem,
where: m.source_id == ^source.id,
where: not is_nil(m.media_filepath)
)
|> Repo.aggregate(:count, :id)
end
defp get_onboarding_layout do
if Settings.get!(:onboarding) do
{Layouts, :onboarding}
@@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do
{"Monthly", 30 * 24 * 60}
]
end
def rss_feed_url(conn, source) do
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end
end
@@ -2,7 +2,9 @@
<.link href={~p"/sources"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Source</h2>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @source.custom_name %>"
</h2>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
@@ -2,7 +2,7 @@
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
<nav>
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
</.button>
</.link>
@@ -14,19 +14,18 @@
<div class="flex flex-col gap-10 min-w-max">
<.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name">
<%= source.custom_name || source.collection_name %>
<.subtle_link href={~p"/sources/#{source.id}"}>
<%= source.custom_name || source.collection_name %>
</.subtle_link>
</:col>
<:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Should Download?">
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
</:col>
<:col :let={source} label="Media Profile">
<.link
href={~p"/media_profiles/#{source.media_profile_id}"}
class="hover:text-secondary duration-200 ease-in-out"
>
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
<%= source.media_profile.name %>
</.link>
</.subtle_link>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
@@ -4,13 +4,13 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Source <span class="hidden sm:inline">#<%= @source.id %></span>
<%= @source.custom_name %>
</h2>
</div>
<nav>
<.link href={~p"/sources/#{@source}/edit"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<.icon name="hero-pencil-square" class="mr-2" /> Edit <span class="hidden sm:inline pl-1">Source</span>
</.button>
</.link>
@@ -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="max-w-full overflow-x-auto">
<.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">
<div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-lg">Attributes</h3>
@@ -31,28 +71,6 @@
<.list_items_from_map map={Map.from_struct(@source)} />
</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-full">
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-full">
Delete Source and Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Pending Media">
<%= if match?([_|_], @pending_media) do %>
@@ -71,7 +89,7 @@
</:tab>
<:tab title="Downloaded Media">
<%= 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">
<:col :let={media_item} label="Title">
<%= StringUtils.truncate(media_item.title, 50) %>
@@ -1,11 +1,22 @@
<.simple_form :let={f} for={@changeset} action={@action}>
<.simple_form
:let={f}
for={@changeset}
action={@action}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<h3 class="mt-8 text-2xl text-black dark:text-white">
General Options
</h3>
<section class="flex justify-between items-center mt-8">
<h3 class=" text-2xl text-black dark:text-white">
General Options
</h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
</span>
</section>
<.input
field={f[:custom_name]}
@@ -28,24 +39,32 @@
Indexing Options
</h3>
<.input
field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()}
type="select"
label="Index Frequency"
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")}>
<section x-data="{ fastIndexingEnabled: null }">
<.input
field={f[:fast_index]}
type="toggle"
label="Use Fast Indexing?"
label_suffix="(pro)"
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()}
type="select"
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"
/>
</div>
<div phx-click={show_modal("upgrade-modal")}>
<.input
field={f[:fast_index]}
type="toggle"
label="Use Fast Indexing"
label_suffix="(pro)"
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>
</section>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Downloading Options
@@ -54,7 +73,7 @@
<.input
field={f[:download_media]}
type="toggle"
label="Download Media?"
label="Download Media"
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
/>
@@ -69,7 +88,24 @@
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
/>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Source</.button>
<section x-show="advancedMode">
<h3 class="mt-8 text-2xl text-black dark:text-white">
Advanced Options
</h3>
<p class="text-sm mt-2">
Tread carefully
</p>
<.input
field={f[:title_filter_regex]}
type="text"
label="Title Filter Regex"
placeholder="(?i)^How to Bike$"
help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source</.button>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.fast_indexing_help />
+49
View File
@@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug :override_base_url
plug :strip_trailing_extension
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
+24 -4
View File
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.Router do
use PinchflatWeb, :router
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
# the extension from the path
pipeline :browser do
plug :basic_auth
plug :accepts, ["html"]
@@ -15,6 +17,10 @@ defmodule PinchflatWeb.Router do
plug :accepts, ["json"]
end
pipeline :feeds do
plug :maybe_basic_auth
end
scope "/", PinchflatWeb do
pipe_through :browser
@@ -28,10 +34,16 @@ defmodule PinchflatWeb.Router do
end
end
# Other scopes may use custom stacks.
# scope "/api", PinchflatWeb do
# pipe_through :api
# end
# Routes in here _may not be_ protected by basic auth. This is necessary for
# media streaming to work for RSS podcast feeds.
scope "/", PinchflatWeb do
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
if Application.compile_env(:pinchflat, :dev_routes) do
@@ -50,6 +62,14 @@ defmodule PinchflatWeb.Router do
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
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "0.1.0-alpha.5",
version: "0.1.4",
elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.AddNfoPathToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :nfo_filepath, :string
add :series_directory, :string
end
end
end
@@ -0,0 +1,15 @@
defmodule Pinchflat.Repo.Migrations.AddSourcePhotosFields do
use Ecto.Migration
def change do
alter table(:sources) do
add :fanart_filepath, :string
add :poster_filepath, :string
add :banner_filepath, :string
end
alter table(:media_profiles) do
add :download_source_images, :boolean, default: false, null: false
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddTitleRegexToSource do
use Ecto.Migration
def change do
alter table(:sources) do
add :title_filter_regex, :string
end
end
end
@@ -0,0 +1,7 @@
defmodule Pinchflat.Repo.Migrations.RemoveUniqueIndexFromSources do
use Ecto.Migration
def change do
drop unique_index(:sources, [:collection_id, :media_profile_id])
end
end
@@ -0,0 +1,11 @@
defmodule Pinchflat.Repo.Migrations.AddImageAttrsToSourceMetadata do
use Ecto.Migration
def change do
alter table(:source_metadata) do
add :fanart_filepath, :string
add :poster_filepath, :string
add :banner_filepath, :string
end
end
end
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 119 KiB

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