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? ## Any other comments?
N/A 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: workflow_dispatch:
inputs: inputs:
image_tag: image_tag:
type: choice
description: 'Docker Image Tag' description: 'Docker Image Tag'
required: true required: true
default: 'dev' 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: jobs:
docker: docker:
@@ -24,9 +36,19 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
platforms: ${{ github.event.inputs.platforms }}
push: true push: true
file: ./selfhosted.Dockerfile 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 alias Pinchflat.SlowIndexing.FileFollowerServer
defmodule IexHelpers do Pinchflat.Release.check_file_permissions()
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
+9 -1
View File
@@ -9,7 +9,15 @@
threshold: :low, threshold: :low,
# All of these are ignorable because this app is intended to be single-user and self-hosted. # All of these are ignorable because this app is intended to be single-user and self-hosted.
# There is an expectation that the user won't intentionally run a FS Traversal on themselves # There is an expectation that the user won't intentionally run a FS Traversal on themselves
ignore: ["CI.System", "Traversal.FileModule", "Config.HTTPS", "Config.CSP"], ignore:
[
"CI.System",
"Traversal.FileModule",
"Config.HTTPS",
"Config.CSP",
"XSS.ContentType",
"Traversal.SendFile"
],
ignore_files: [], ignore_files: [],
version: false version: false
] ]
+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 ## 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 ### Portainer
docker build . --file selfhosted.Dockerfile -t pinchflat:dev
docker run \ Docker Compose file:
-p 8945:8945 \
-v /Users/work/Desktop/test_volumes/config:/config \ ```yaml
-v /Users/work/Desktop/test_volumes/downloads:/downloads \ version: '3'
pinchflat:dev 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 ## License
See `LICENSE` file See `LICENSE` file
```
```
+24
View File
@@ -38,6 +38,30 @@ let liveSocket = new LiveSocket('/live', Socket, {
} }
}) })
window.copyTextToClipboard = async (text) => {
// Navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const textArea = document.createElement('textarea')
textArea.value = text
// Move textarea out of the viewport so it's not visible
textArea.style.position = 'absolute'
textArea.style.left = '-999999px'
document.body.prepend(textArea)
textArea.select()
try {
document.execCommand('copy')
} catch (error) {
console.error(error)
} finally {
textArea.remove()
}
}
}
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300)) window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300))
+4 -2
View File
@@ -16,11 +16,13 @@ config :pinchflat,
media_directory: "/downloads", media_directory: "/downloads",
# The user may or may not store metadata for their needs, but the app will always store its copy # The user may or may not store metadata for their needs, but the app will always store its copy
metadata_directory: "/config/metadata", metadata_directory: "/config/metadata",
extras_directory: "/config/extras",
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
# Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth. # Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth.
# If either is unset, basic auth will not be used. # If either is unset, basic auth will not be used.
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), basic_auth_username: "",
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"), basic_auth_password: "",
expose_feed_endpoints: false,
file_watcher_poll_interval: 1000 file_watcher_poll_interval: 1000
# Configures the endpoint # Configures the endpoint
+1
View File
@@ -3,6 +3,7 @@ import Config
config :pinchflat, config :pinchflat,
media_directory: Path.join([File.cwd!(), "tmp", "media"]), media_directory: Path.join([File.cwd!(), "tmp", "media"]),
metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"]), metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"]),
extras_directory: Path.join([File.cwd!(), "tmp", "extras"]),
tmpfile_directory: Path.join([File.cwd!(), "tmp", "tmpfiles"]) tmpfile_directory: Path.join([File.cwd!(), "tmp", "tmpfiles"])
# Configure your database # Configure your database
+26 -59
View File
@@ -25,17 +25,30 @@ config :pinchflat,
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD") basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
if config_env() == :prod do arch_string = to_string(:erlang.system_info(:system_architecture))
config_path =
System.get_env("CONFIG_PATH") ||
raise """
environment variable CONFIG_PATH is missing.
For example: /etc/pinchflat/config
"""
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"])) db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"])) log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"])) metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
# 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 # We want to force _some_ level of useful logging in production
acceptable_log_levels = ~w(debug info)a acceptable_log_levels = ~w(debug info)a
@@ -50,8 +63,12 @@ if config_env() == :prod do
config :pinchflat, config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
media_directory: "/downloads",
metadata_directory: metadata_path, 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, config :pinchflat, Pinchflat.Repo,
database: db_path, 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. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses. # for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}, ip: {0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000") port: String.to_integer(System.get_env("PORT") || "4000")
], ],
secret_key_base: secret_key_base secret_key_base: secret_key_base
@@ -108,54 +125,4 @@ if config_env() == :prod do
formatter: Logger.Formatter.new() formatter: Logger.Formatter.new()
}} }}
] ]
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :pinchflat, PinchflatWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :pinchflat, PinchflatWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :pinchflat, Pinchflat.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end end
+2 -15
View File
@@ -18,7 +18,6 @@ defmodule Pinchflat.Boot.DataBackfillWorker do
alias __MODULE__ alias __MODULE__
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media.MediaItem
@doc """ @doc """
Cancels all pending backfill jobs. Useful for ensuring worker runs immediately 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 def perform(%Oban.Job{}) do
Logger.info("Running data backfill worker") 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() reschedule_backfill()
:ok :ok
end 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 defp reschedule_backfill do
# Run hourly # Run hourly
next_run_in = 60 * 60 next_run_in = 60 * 60
+25 -46
View File
@@ -31,59 +31,38 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
""" """
@impl true @impl true
def init(state) do def init(state) do
reset_executing_jobs()
create_blank_cookie_file()
apply_default_settings() apply_default_settings()
ensure_directories_are_writeable()
rename_old_job_workers()
{:ok, state} {:ok, state}
end end
# If a node cannot gracefully shut down, the currently executing jobs get stuck
# in the "executing" state. This is a problem because the job runner will not
# pick them up again
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 defp apply_default_settings do
Settings.fetch!(:onboarding, true) Settings.fetch!(:onboarding, true)
Settings.fetch!(:pro_enabled, false) Settings.fetch!(:pro_enabled, false)
end 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 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. 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.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder alias Pinchflat.Downloading.OutputPathBuilder
@@ -26,6 +27,18 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
{:ok, built_options} {:ok, built_options}
end 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 defp default_options do
[:no_progress, :windows_filenames] [:no_progress, :windows_filenames]
end end
@@ -104,23 +117,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
defp output_options(media_item_with_preloads) do 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 end
defp build_output_path(string, media_item_with_preloads) do defp build_output_path(string, source) do
additional_options_map = output_options_map(media_item_with_preloads) additional_options_map = output_options_map(source)
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map) {:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
Path.join(base_directory(), output_path) Path.join(base_directory(), output_path)
end end
defp output_options_map(media_item_with_preloads) do defp output_options_map(source) do
source = media_item_with_preloads.source
%{ %{
"source_custom_name" => source.custom_name, "source_custom_name" => source.custom_name,
"source_collection_type" => source.collection_type "source_collection_type" => source.collection_type
@@ -137,7 +146,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|> String.split(~r{\.}, include_captures: true) |> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb") |> List.insert_at(-3, "-thumb")
|> Enum.join() |> Enum.join()
|> build_output_path(media_item_with_preloads) |> build_output_path(media_item_with_preloads.source)
end end
defp base_directory do defp base_directory do
@@ -56,7 +56,9 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp determine_nfo_filepath(media_item, parsed_json) do defp determine_nfo_filepath(media_item, parsed_json) do
if media_item.source.media_profile.download_nfo 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 else
nil nil
end end
@@ -42,7 +42,8 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
"upload_day" => "%(upload_date>%d)S", "upload_day" => "%(upload_date>%d)S",
"upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S", "upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S",
"season_from_date" => "%(upload_date>%Y)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
end end
+26 -2
View File
@@ -20,6 +20,21 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
filepath filepath
end 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 """ @doc """
Writes content to a file, creating directories as needed. Writes content to a file, creating directories as needed.
Takes the same args as File.write!/3. Takes the same args as File.write!/3.
@@ -27,11 +42,20 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
Returns :ok | raises on error Returns :ok | raises on error
""" """
def write_p!(filepath, content, modes \\ []) do 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() |> Path.dirname()
|> File.mkdir_p!() |> File.mkdir_p!()
File.write!(filepath, content, modes) File.cp!(source, destination)
end end
@doc """ @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([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile)) |> where(^build_format_clauses(media_profile))
|> where(^maybe_apply_cutoff_date(source)) |> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -100,6 +101,7 @@ defmodule Pinchflat.Media do
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath)) |> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_item.source.media_profile)) |> where(^build_format_clauses(media_item.source.media_profile))
|> where(^maybe_apply_cutoff_date(media_item.source)) |> where(^maybe_apply_cutoff_date(media_item.source))
|> where(^maybe_apply_title_regex(media_item.source))
|> Repo.exists?() |> Repo.exists?()
end end
@@ -247,6 +249,14 @@ defmodule Pinchflat.Media do
end end
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 defp build_format_clauses(media_profile) do
mapped_struct = Map.from_struct(media_profile) mapped_struct = Map.from_struct(media_profile)
+8
View File
@@ -5,6 +5,7 @@ defmodule Pinchflat.Media.MediaItem do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
@@ -32,6 +33,7 @@ defmodule Pinchflat.Media.MediaItem do
] ]
# Pretty much all the fields captured at index are required. # Pretty much all the fields captured at index are required.
@required_fields ~w( @required_fields ~w(
uuid
title title
original_url original_url
livestream livestream
@@ -42,6 +44,11 @@ defmodule Pinchflat.Media.MediaItem do
)a )a
schema "media_items" do schema "media_items" do
# This is _not_ used as the primary key or internally in the database
# relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :title, :string field :title, :string
field :media_id, :string field :media_id, :string
field :description, :string field :description, :string
@@ -78,6 +85,7 @@ defmodule Pinchflat.Media.MediaItem do
media_item media_item
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false) |> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(@required_fields) |> validate_required(@required_fields)
|> unique_constraint([:media_id, :source_id]) |> unique_constraint([:media_id, :source_id])
end end
@@ -11,6 +11,22 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
alias Pinchflat.Filesystem.FilesystemHelpers 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 """ @doc """
Compresses and stores metadata for a media item, returning the filepath. 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}") Date.from_iso8601!("#{year}-#{month}-#{day}")
end 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 defp fetch_thumbnail_from_url(url) do
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient) http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
{:ok, body} = http_client.get(url, [], body_format: :binary) {:ok, body} = http_client.get(url, [], body_format: :binary)
@@ -71,13 +124,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
end end
defp generate_filepath_for(database_record, filename) do 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([ Path.join([
metadata_directory, metadata_directory_for(database_record),
record_table_name,
to_string(database_record.id),
filename filename
]) ])
end 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 # NOTE: whole ordeal needed due to a bug I found in yt-dlp
# https://github.com/yt-dlp/yt-dlp/issues/9445 # https://github.com/yt-dlp/yt-dlp/issues/9445
# Can be reverted to remove this entire conditional once fixed # 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: filepath_if_exists(filepath)
thumbnail_filepath
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
} }
else else
%{ %{
thumbnail_filepath: thumbnail_filepath thumbnail_filepath: nil
} }
end end
end end
defp parse_infojson_metadata(metadata) do defp parse_infojson_metadata(metadata) do
%{ %{
metadata_filepath: metadata["infojson_filename"] metadata_filepath: filepath_if_exists(metadata["infojson_filename"])
} }
end end
# NOTE: this should not be needed, but it is due to a bug in yt-dlp.
# Can remove once this is resolved:
# https://github.com/yt-dlp/yt-dlp/issues/9445#issuecomment-2018724344
defp filepath_if_exists(nil), do: nil
defp filepath_if_exists(filepath) do
if File.exists?(filepath) do
filepath
else
nil
end
end
end end
+29 -5
View File
@@ -9,13 +9,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
@doc """ @doc """
Builds an NFO file for a media item (read: single "episode") and 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 stores it at the specified location.
as the media file, but with a .nfo extension.
Returns the filepath of the NFO file. Returns the filepath of the NFO file.
""" """
def build_and_store_for_media_item(metadata) do def build_and_store_for_media_item(filepath, metadata) do
filepath = Path.rootname(metadata["filepath"]) <> ".nfo"
nfo = build_for_media_item(metadata) nfo = build_for_media_item(metadata)
FilesystemHelpers.write_p!(filepath, nfo) FilesystemHelpers.write_p!(filepath, nfo)
@@ -23,6 +21,20 @@ defmodule Pinchflat.Metadata.NfoBuilder do
filepath filepath
end 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 defp build_for_media_item(metadata) do
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"]) upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub. # 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> <showtitle>#{metadata["uploader"]}</showtitle>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid> <uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<plot>#{metadata["description"]}</plot> <plot>#{metadata["description"]}</plot>
<premiered>#{upload_date}</premiered> <aired>#{upload_date}</aired>
<season>#{upload_date.year}</season> <season>#{upload_date.year}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode> <episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<genre>YouTube</genre> <genre>YouTube</genre>
</episodedetails> </episodedetails>
""" """
end 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 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 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 @required_fields ~w(metadata_filepath)a
schema "source_metadata" do schema "source_metadata" do
field :metadata_filepath, :string field :metadata_filepath, :string
field :fanart_filepath, :string
field :poster_filepath, :string
field :banner_filepath, :string
belongs_to :source, Source belongs_to :source, Source
@@ -31,6 +40,11 @@ defmodule Pinchflat.Metadata.SourceMetadata do
@doc false @doc false
def filepath_attributes do def filepath_attributes do
~w(metadata_filepath)a ~w(
metadata_filepath
fanart_filepath
poster_filepath
banner_filepath
)a
end end
end end
@@ -4,10 +4,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
use Oban.Worker, use Oban.Worker,
queue: :remote_metadata, queue: :remote_metadata,
tags: ["media_source", "source_metadata", "remote_metadata"], tags: ["media_source", "source_metadata", "remote_metadata"],
max_attempts: 1, max_attempts: 3
# This is the only thing stopping this job from calling itself
# in an infinite loop.
unique: [period: 600]
require Logger require Logger
@@ -15,8 +12,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Utils.StringUtils
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceImageParser
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Downloading.DownloadOptionBuilder
@doc """ @doc """
Starts the source metadata storage worker and creates a task for the source. Starts the source metadata storage worker and creates a task for the source.
@@ -30,27 +31,86 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
end end
@doc """ @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 Returns :ok
""" """
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id}}) do def perform(%Oban.Job{args: %{"id" => source_id}}) do
source = Repo.preload(Sources.get_source!(source_id), :metadata) source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile])
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url) series_directory = determine_series_directory(source)
# Since updating a source kicks this job off again, we enforce job uniqueness (above) {source_metadata, source_image_attrs, metadata_image_attrs} =
# to once, per source, per x minutes. This is to prevent a job from calling itself fetch_source_metadata_and_images(series_directory, source)
# in an infinite loop.
Sources.update_source(source, %{ source_metadata_filepath = MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata)
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(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 :ok
rescue rescue
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found") Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale") Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end 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 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 sub_langs
download_thumbnail download_thumbnail
embed_thumbnail embed_thumbnail
download_source_images
download_metadata download_metadata
embed_metadata embed_metadata
download_nfo download_nfo
@@ -40,6 +41,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :download_thumbnail, :boolean, default: false field :download_thumbnail, :boolean, default: false
field :embed_thumbnail, :boolean, default: false field :embed_thumbnail, :boolean, default: false
field :download_source_images, :boolean, default: false
field :download_metadata, :boolean, default: false field :download_metadata, :boolean, default: false
field :embed_metadata, :boolean, default: false field :embed_metadata, :boolean, default: false
+62
View File
@@ -5,6 +5,10 @@ defmodule Pinchflat.Release do
""" """
@app :pinchflat @app :pinchflat
require Logger
alias Pinchflat.Filesystem.FilesystemHelpers
def migrate do def migrate do
load_app() load_app()
@@ -18,6 +22,37 @@ defmodule Pinchflat.Release do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end 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 defp repos do
Application.fetch_env!(@app, :ecto_repos) Application.fetch_env!(@app, :ecto_repos)
end end
@@ -25,4 +60,31 @@ defmodule Pinchflat.Release do
defp load_app do defp load_app do
Application.load(@app) Application.load(@app)
end 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 end
@@ -9,7 +9,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
require Logger require Logger
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval) @poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
@activity_timeout_ms 60_000 @activity_timeout_ms 600_000
# Client API # Client API
@doc """ @doc """
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
require Logger require Logger
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources 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 # See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it) # (important reading if you're not familiar with it)
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source) {: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 = result =
Enum.map(media_attributes, fn media_attrs -> Enum.map(media_attributes, fn media_attrs ->
@@ -117,6 +121,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
end end
defp create_media_item_and_enqueue_download(source, media_attrs) do defp create_media_item_and_enqueue_download(source, media_attrs) do
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
case Media.create_media_item_from_backend_attrs(source, media_attrs) do case Media.create_media_item_from_backend_attrs(source, media_attrs) do
{:ok, %MediaItem{} = media_item} -> {:ok, %MediaItem{} = media_item} ->
if source.download_media && Media.pending_download?(media_item) do 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_id
collection_type collection_type
custom_name custom_name
description
nfo_filepath
poster_filepath
fanart_filepath
banner_filepath
series_directory
index_frequency_minutes index_frequency_minutes
fast_index fast_index
download_media download_media
last_indexed_at last_indexed_at
original_url original_url
download_cutoff_date download_cutoff_date
title_filter_regex
media_profile_id media_profile_id
)a )a
@@ -41,6 +48,7 @@ defmodule Pinchflat.Sources.Source do
@pre_insert_required_fields @initially_required_fields ++ @pre_insert_required_fields @initially_required_fields ++
~w( ~w(
uuid
custom_name custom_name
collection_name collection_name
collection_id collection_id
@@ -48,7 +56,13 @@ defmodule Pinchflat.Sources.Source do
)a )a
schema "sources" do schema "sources" do
# This is _not_ used as the primary key or internally in the database
# relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :custom_name, :string field :custom_name, :string
field :description, :string
field :collection_name, :string field :collection_name, :string
field :collection_id, :string field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist] field :collection_type, Ecto.Enum, values: [:channel, :playlist]
@@ -59,6 +73,13 @@ defmodule Pinchflat.Sources.Source do
# Only download media items that were published after this date # Only download media items that were published after this date
field :download_cutoff_date, :date field :download_cutoff_date, :date
field :original_url, :string 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 belongs_to :media_profile, MediaProfile
@@ -83,9 +104,9 @@ defmodule Pinchflat.Sources.Source do
source source
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end) |> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields) |> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false) |> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id])
end end
@doc false @doc false
@@ -99,4 +120,9 @@ defmodule Pinchflat.Sources.Source do
# minutes # minutes
15 15
end end
@doc false
def filepath_attributes do
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
end
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 though we know it's going to fail so it picks up any addl. database errors
and fulfills our return contract. 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{}} Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
""" """
def create_source(attrs) do def create_source(attrs, opts \\ []) do
case change_source(%Source{}, attrs, :initial) do case change_source(%Source{}, attrs, :initial) do
%Ecto.Changeset{valid?: true} -> %Ecto.Changeset{valid?: true} ->
%Source{} %Source{}
|> maybe_change_source_from_url(attrs) |> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency() |> maybe_change_indexing_frequency()
|> commit_and_handle_tasks() |> commit_and_handle_tasks(opts)
changeset -> changeset ->
Repo.insert(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 though we know it's going to fail so it picks up any addl. database errors
and fulfills our return contract. 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{}} 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 case change_source(source, attrs, :initial) do
%Ecto.Changeset{valid?: true} -> %Ecto.Changeset{valid?: true} ->
source source
|> maybe_change_source_from_url(attrs) |> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency() |> maybe_change_indexing_frequency()
|> commit_and_handle_tasks() |> commit_and_handle_tasks(opts)
changeset -> changeset ->
Repo.update(changeset) Repo.update(changeset)
@@ -102,7 +110,6 @@ defmodule Pinchflat.Sources do
""" """
def delete_source(%Source{} = source, opts \\ []) do def delete_source(%Source{} = source, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false) delete_files = Keyword.get(opts, :delete_files, false)
Tasks.delete_tasks_for(source) Tasks.delete_tasks_for(source)
source source
@@ -111,7 +118,11 @@ defmodule Pinchflat.Sources do
Media.delete_media_item(media_item, delete_files: delete_files) Media.delete_media_item(media_item, delete_files: delete_files)
end) end)
delete_source_metadata_files(source) if delete_files do
delete_source_files(source)
end
delete_internal_metadata_files(source)
Repo.delete(source) Repo.delete(source)
end end
@@ -134,22 +145,27 @@ defmodule Pinchflat.Sources do
end end
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{} metadata = Repo.preload(source, :metadata).metadata || %SourceMetadata{}
mapped_struct = Map.from_struct(metadata) mapped_struct = Map.from_struct(metadata)
filepaths = SourceMetadata.filepath_attributes()
SourceMetadata.filepath_attributes() |> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.map(fn field -> mapped_struct[field] end) |> Enum.filter(&is_binary/1)
|> Enum.filter(&is_binary/1) |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
Enum.each(filepaths, &FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end end
defp add_source_details_to_changeset(source, changeset) do defp add_source_details_to_changeset(source, changeset) do
%Ecto.Changeset{changes: changes} = changeset case MediaCollection.get_source_details(changeset.changes.original_url) do
case MediaCollection.get_source_details(changes.original_url) do
{:ok, source_details} -> {:ok, source_details} ->
add_source_details_by_collection_type(source, changeset, source_details) add_source_details_by_collection_type(source, changeset, source_details)
@@ -198,12 +214,16 @@ defmodule Pinchflat.Sources do
end end
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 case Repo.insert_or_update(changeset) do
{:ok, %Source{} = source} -> {:ok, %Source{} = source} ->
maybe_handle_media_tasks(changeset, source) if run_post_commit_tasks do
maybe_run_indexing_task(changeset, source) maybe_handle_media_tasks(changeset, source)
run_metadata_storage_task(source) maybe_run_indexing_task(changeset, source)
run_metadata_storage_task(source)
end
{:ok, source} {: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. # 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)) output_filepath = Keyword.get(addl_opts, :output_filepath, FSUtils.generate_metadata_tmpfile(:json))
print_to_file_opts = [{:print_to_file, output_template}, output_filepath] 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, " ")}") Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
@@ -47,6 +48,19 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
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: # 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) # 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, ...}. 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 # `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about # the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the source details # available formats since we're just getting the source details
opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1] command_opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1] ++ addl_opts
output_template = "%(.{channel,channel_id,playlist_id,playlist_title})j" 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, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)} {:ok, format_source_details(parsed_json)}
else else
@@ -94,8 +94,8 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
def get_source_metadata(source_url) do def get_source_metadata(source_url, addl_opts \\ []) do
opts = [playlist_items: 0] opts = [playlist_items: 0] ++ addl_opts
output_template = "playlist:%()j" output_template = "playlist:%()j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template), with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
@@ -112,7 +112,11 @@ defmodule Pinchflat.YtDlp.MediaCollection do
channel_id: response["channel_id"], channel_id: response["channel_id"],
channel_name: response["channel"], channel_name: response["channel"],
playlist_id: response["playlist_id"], 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 end
+1 -1
View File
@@ -21,7 +21,7 @@ defmodule PinchflatWeb do
def router do def router do
quote do quote do
use Phoenix.Router, helpers: false use Phoenix.Router, helpers: true
# Import common connection and controller functions to use in pipelines # Import common connection and controller functions to use in pipelines
import Plug.Conn import Plug.Conn
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
""" """
use Phoenix.Component use Phoenix.Component, global_prefixes: ~w(x-)
import PinchflatWeb.Gettext import PinchflatWeb.Gettext
@@ -347,8 +347,9 @@ defmodule PinchflatWeb.CoreComponents do
id={@id} id={@id}
name={@name} name={@name}
class={[ class={[
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition", "relative z-20 w-full appearance-none rounded border border-form-strokedark py-3 pl-5 pr-12 outline-none transition",
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white", "focus:border-primary active:border-primary bg-form-input text-black text-white",
"disabled:text-opacity-50 disabled:cursor-not-allowed disabled:border-black",
@inputclass @inputclass
]} ]}
multiple={@multiple} multiple={@multiple}
@@ -654,10 +655,11 @@ defmodule PinchflatWeb.CoreComponents do
""" """
attr :name, :string, required: true attr :name, :string, required: true
attr :class, :string, default: nil attr :class, :string, default: nil
attr :rest, :global
def icon(%{name: "hero-" <> _} = assigns) do def icon(%{name: "hero-" <> _} = assigns) do
~H""" ~H"""
<span class={[@name, @class]} /> <span class={[@name, @class]} {@rest} />
""" """
end end
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.CustomComponents.ButtonComponents do defmodule PinchflatWeb.CustomComponents.ButtonComponents do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.Component, global_prefixes: ~w(x-)
alias PinchflatWeb.CoreComponents
@doc """ @doc """
Render a button Render a button
@@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
</button> </button>
""" """
end end
@doc """
Render a dropdown based off a button
## Examples
<.button_dropdown text="Actions">
<:option>TEST</:option>
</.button_dropdown>
"""
attr :text, :string, required: true
attr :class, :string, default: ""
slot :option, required: true
def button_dropdown(assigns) do
~H"""
<div x-data="{ dropdownOpen: false }" class={["relative flex", @class]}>
<span
x-on:click.prevent="dropdownOpen = !dropdownOpen"
class={[
"cursor-pointer inline-flex gap-2.5 rounded-md bg-primary px-5.5 py-3",
"font-medium text-white hover:bg-opacity-95"
]}
>
<%= @text %>
<CoreComponents.icon
name="hero-chevron-down"
class="fill-current duration-200 ease-linear mt-1"
x-bind:class="dropdownOpen && 'rotate-180'"
/>
</span>
<div
x-show="dropdownOpen"
x-on:click.outside="dropdownOpen = false"
class="absolute left-0 top-full z-40 mt-2 w-full rounded-md bg-black py-3 shadow-card"
>
<ul class="flex flex-col">
<li :for={option <- @option}>
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
<%= render_slot(option) %>
</span>
</li>
</ul>
</div>
</div>
"""
end
end end
@@ -9,24 +9,31 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
attr :title, :string, required: true attr :title, :string, required: true
end end
slot :tab_append, required: false
def tabbed_layout(assigns) do def tabbed_layout(assigns) do
~H""" ~H"""
<div <div
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }" x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
class="w-full" class="w-full"
> >
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10"> <header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
<a <div class="flex flex-wrap gap-5 sm:gap-10">
:for={{tab, idx} <- Enum.with_index(@tab)} <a
href="#" :for={{tab, idx} <- Enum.with_index(@tab)}
@click.prevent={"openTab = #{idx}"} href="#"
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"} @click.prevent={"openTab = #{idx}"}
class="border-b-2 py-4 text-sm font-medium hover:text-meta-5 md:text-base" 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> <span class="text-xl"><%= tab.title %></span>
</div> </a>
<div> </div>
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
<%= render_slot(@tab_append) %>
</div>
</header>
<div class="mt-4">
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed"> <div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
<%= render_slot(tab) %> <%= render_slot(tab) %>
</div> </div>
@@ -31,6 +31,20 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
""" """
end 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 """ @doc """
Renders an icon as a link with the given href. Renders an icon as a link with the given href.
""" """
@@ -1,10 +1,9 @@
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden"> <div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<header 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 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="w-65 px-4 py-2 shadow-2 md:px-6">
<div class="flex items-center"> <div class="flex items-center gap-2 py-2">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" /> <img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</div> </div>
</div> </div>
</header> </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 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 <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" @click.stop="sidebarVisible = !sidebarVisible"
> >
<.icon name="hero-bars-3" /> <.icon name="hero-bars-3" />
</button> </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>
<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"> <div class="relative">
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex"> <span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
<.icon name="hero-magnifying-glass" /> <.icon name="hero-magnifying-glass" />
@@ -23,7 +23,7 @@
name="q" name="q"
value={@params["q"]} value={@params["q"]}
placeholder="Type to search..." 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> </form>
</div> </div>
@@ -1,24 +1,23 @@
<aside <aside
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'" x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
class={[ 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" "bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0"
]} ]}
@click.outside="sidebarVisible = false" @click.outside="sidebarVisible = false"
> >
<section> <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"> <a href="/" class="flex items-center">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" /> <img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</a> </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" /> <.icon name="hero-arrow-left" class="fill-current" />
</button> </button>
</div> </div>
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear"> <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> <h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
<div class="flex flex-col justify-between"> <div class="flex flex-col justify-between">
<ul class="mb-6 flex flex-col gap-1.5"> <ul class="mb-6 flex flex-col gap-1.5">
@@ -31,8 +30,14 @@
</div> </div>
</section> </section>
<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"> <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 <.sidebar_item
icon="hero-code-bracket" icon="hero-code-bracket"
text="Github" text="Github"
@@ -52,6 +57,11 @@
<.icon name="hero-currency-dollar" /> Donate <.icon name="hero-currency-dollar" /> Donate
</span> </span>
</li> </li>
<li>
<span class="group relative flex items-center gap-2.5 px-4 py-2 text-sm">
v<%= Application.spec(:pinchflat)[:vsn] %>
</span>
</li>
</ul> </ul>
</nav> </nav>
</section> </section>
@@ -8,7 +8,7 @@
<%= assigns[:page_title] || "Pinchflat" %> <%= assigns[:page_title] || "Pinchflat" %>
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-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 defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
</head> </head>
@@ -1,8 +1,11 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
require Logger
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_item = media_item =
@@ -29,4 +32,69 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|> put_flash(:info, flash_message) |> put_flash(:info, flash_message)
|> redirect(to: ~p"/sources/#{media_item.source_id}") |> redirect(to: ~p"/sources/#{media_item.source_id}")
end end
# See here for details on streaming files and range requests:
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
#
# Uses the UUID instead of the ID to avoid enumeration attacks
# since streaming is a public endpoint (ie: no auth required)
def stream(conn, %{"uuid" => uuid}) do
media_item = Repo.get_by!(MediaItem, uuid: uuid)
if File.exists?(media_item.media_filepath) do
file_size = File.stat!(media_item.media_filepath).size
mime_type = MIME.from_path(media_item.media_filepath)
case parse_range(conn, file_size) do
{:ok, {start_pos, end_pos}} ->
Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{end_pos}")
length = end_pos - start_pos + 1
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|> put_resp_header("content-length", to_string(length))
|> send_file(206, media_item.media_filepath, start_pos, length)
{:error, :invalid_range} ->
Logger.debug("Invalid range request for media item: #{media_item.uuid} - serving full file")
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("content-length", to_string(file_size))
|> put_resp_header("accept-ranges", "bytes")
|> send_file(200, media_item.media_filepath)
end
else
send_resp(conn, 404, "File not found")
end
end
defp parse_range(conn, file_size) do
with [range_header | _] <- get_req_header(conn, "range"),
["bytes", range] <- String.split(range_header, "="),
[start_pos, end_pos] <- String.split(range, "-") do
validate_range(start_pos, end_pos, file_size)
else
_ -> {:error, :invalid_range}
end
end
defp validate_range(start_pos, end_pos, file_size) do
case {Integer.parse(start_pos), Integer.parse(end_pos)} do
{:error, :error} ->
{:error, :invalid_range}
{{start_pos, _}, :error} ->
{:ok, {start_pos, file_size - 1}}
# See RFC7233
{{start_pos, _}, {end_pos, _}} when end_pos >= file_size ->
{:ok, {start_pos, file_size - 1}}
{{start_pos, _}, {end_pos, _}} ->
{:ok, {start_pos, end_pos}}
end
end
end end
@@ -2,4 +2,16 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
embed_templates "media_item_html/*" embed_templates "media_item_html/*"
def media_file_exists?(media_item) do
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
end
def media_type(media_item) do
case Path.extname(media_item.media_filepath) do
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
ext when ext in [".mp3", ".m4a"] -> :audio
_ -> :unknown
end
end
end end
@@ -0,0 +1,13 @@
<%= if media_type(@media_item) == :video do %>
<video controls class="max-h-96 w-full lg:w-2/3 xl:w-1/2">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
Your browser does not support the video element.
</video>
<% end %>
<%= if media_type(@media_item) == :audio do %>
<audio controls class="w-full lg:w-2/3 xl:w-1/2">
<source src={~p"/media/#{@media_item.uuid}/stream"} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
<% end %>
@@ -8,11 +8,30 @@
</h2> </h2>
</div> </div>
</div> </div>
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5"> <div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
Delete Files
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes"> <:tab title="Attributes">
<div class="flex flex-col gap-10 dark:text-white"> <div class="flex flex-col gap-10 dark:text-white">
<%= if media_file_exists?(@media_item) do %>
<h3 class="font-bold text-xl">Preview</h3>
<.media_preview media_item={@media_item} />
<% end %>
<h3 class="font-bold text-xl">Attributes</h3> <h3 class="font-bold text-xl">Attributes</h3>
<section> <section>
<strong>Source:</strong> <strong>Source:</strong>
@@ -23,18 +42,6 @@
<.list_items_from_map map={Map.from_struct(@media_item)} /> <.list_items_from_map map={Map.from_struct(@media_item)} />
</div> </div>
<section class="flex justify-center my-10">
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Files
</.button>
</.link>
</section>
</:tab> </:tab>
<:tab title="Tasks"> <:tab title="Tasks">
<%= if match?([_|_], @media_item.tasks) do %> <%= if match?([_|_], @media_item.tasks) do %>
@@ -39,7 +39,8 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
upload_year: nil, upload_year: nil,
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD", 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_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 end
@@ -74,6 +75,6 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
end end
defp audio_output_template do defp audio_output_template do
"/music/{{ source_custom_name }}/{{ title }}.{{ ext }}" "/music/{{ artist_name }}/{{ title }}.{{ ext }}"
end end
end end
@@ -2,7 +2,9 @@
<.link href={~p"/media_profiles"}> <.link href={~p"/media_profiles"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" /> <.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link> </.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">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>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1"> <div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
@@ -4,7 +4,7 @@
</h2> </h2>
<nav> <nav>
<.link href={~p"/media_profiles/new"}> <.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> <span class="font-bold text-xl mx-2">+</span> New <span class="hidden sm:inline pl-1">Media Profile</span>
</.button> </.button>
</.link> </.link>
@@ -16,10 +16,9 @@
<div class="flex flex-col gap-10 min-w-max"> <div class="flex flex-col gap-10 min-w-max">
<.table rows={@media_profiles} table_class="text-black dark:text-white"> <.table rows={@media_profiles} table_class="text-black dark:text-white">
<:col :let={media_profile} label="Name"> <:col :let={media_profile} label="Name">
<%= media_profile.name %> <.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
</:col> <%= media_profile.name %>
<:col :let={media_profile} label="Output Template"> </.subtle_link>
<code class="text-sm"><%= media_profile.output_path_template %></code>
</:col> </:col>
<:col :let={media_profile} label="Preferred Resolution"> <:col :let={media_profile} label="Preferred Resolution">
<%= media_profile.preferred_resolution %> <%= media_profile.preferred_resolution %>
@@ -38,7 +38,7 @@
presets: { presets: {
default: 'Default', default: 'Default',
media_center: 'TV Shows', media_center: 'TV Shows',
audio: 'Audio', audio: 'Music',
archiving: 'Archiving' archiving: 'Archiving'
} }
}"> }">
@@ -162,16 +162,6 @@
/> />
</section> </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"> <h3 class="mt-10 text-2xl text-black dark:text-white">
Release Format Options Release Format Options
</h3> </h3>
@@ -181,7 +171,7 @@
field={f[:shorts_behaviour]} field={f[:shorts_behaviour]}
options={friendly_format_type_options()} options={friendly_format_type_options()}
type="select" type="select"
label="Include Shorts?" label="Include Shorts"
help="Experimental. Please report any issues on GitHub" help="Experimental. Please report any issues on GitHub"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))" x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/> />
@@ -192,7 +182,7 @@
field={f[:livestream_behaviour]} field={f[:livestream_behaviour]}
options={friendly_format_type_options()} options={friendly_format_type_options()}
type="select" type="select"
label="Include Livestreams?" label="Include Livestreams"
help="Excludes media that comes from a past livestream" help="Excludes media that comes from a past livestream"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))" x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/> />
@@ -213,7 +203,42 @@
/> />
</section> </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> </section>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5"> <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" /> <.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link> </.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2"> <h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
Media Profile <span class="hidden sm:inline">#<%= @media_profile.id %></span> <%= @media_profile.name %>
</h2> </h2>
</div> </div>
<nav> <nav>
<.link href={~p"/media_profiles/#{@media_profile}/edit"}> <.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> <.icon name="hero-pencil-square" class="mr-2" />Edit <span class="hidden sm:inline pl-1">Media Profile</span>
</.button> </.button>
</.link> </.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="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_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"> <:tab title="Attributes">
<div class="flex flex-col gap-10 text-white"> <div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3> <h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
<.list_items_from_map map={Map.from_struct(@media_profile)} /> <.list_items_from_map map={Map.from_struct(@media_profile)} />
</div> </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>
<:tab title="Sources"> <:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white"> <.table rows={@media_profile.sources} table_class="text-black dark:text-white">
@@ -11,7 +11,7 @@
<p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p> <p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p>
<div class="mt-8"> <div class="mt-8">
<.link href={~p"/media_profiles/new"}> <.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 <span class="font-bold mx-2">+</span> New Media Profile
</.button> </.button>
</.link> </.link>
@@ -25,7 +25,7 @@
</p> </p>
<div class="mt-8"> <div class="mt-8">
<.link href={~p"/sources/new"}> <.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 <span class="font-bold mx-2">+</span> New Source
</.button> </.button>
</.link> </.link>
@@ -40,7 +40,7 @@
<p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p> <p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p>
<div class="mt-8"> <div class="mt-8">
<.link href={~p"/?onboarding=0"}> <.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> Let's Go <span class="font-bold mx-2">🚀</span>
</.button> </.button>
</.link> </.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"> <div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white"> <h2 class="text-title-md2 font-bold text-black dark:text-white">
<span class="hidden sm:inline">Search </span>Results for "<%= StringUtils.truncate(@search_term, 50) %>" Results for "<%= StringUtils.truncate(@search_term, 50) %>"
</h2> </h2>
</div> </div>
@@ -10,7 +10,7 @@
<%= if match?([_|_], @search_results) do %> <%= if match?([_|_], @search_results) do %>
<.table rows={@search_results} table_class="text-black dark:text-white"> <.table rows={@search_results} table_class="text-black dark:text-white">
<:col :let={result} label="Title"> <:col :let={result} label="Title">
<%= StringUtils.truncate(result.title, 40) %> <%= result.title %>
</:col> </:col>
<:col :let={result} label="Excerpt"> <:col :let={result} label="Excerpt">
<.highlight_search_terms text={result.matching_search_term} /> <.highlight_search_terms text={result.matching_search_term} />
@@ -1,12 +1,15 @@
defmodule PinchflatWeb.Sources.SourceController do defmodule PinchflatWeb.Sources.SourceController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
import Ecto.Query, warn: false
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
def index(conn, _params) do def index(conn, _params) do
sources = Repo.preload(Sources.list_sources(), :media_profile) sources = Repo.preload(Sources.list_sources(), :media_profile)
@@ -54,7 +57,8 @@ defmodule PinchflatWeb.Sources.SourceController do
source: source, source: source,
pending_tasks: pending_tasks, pending_tasks: pending_tasks,
pending_media: pending_media, pending_media: pending_media,
downloaded_media: downloaded_media downloaded_media: downloaded_media,
total_downloaded: total_downloaded_for(source)
) )
end end
@@ -104,6 +108,17 @@ defmodule PinchflatWeb.Sources.SourceController do
Profiles.list_media_profiles() Profiles.list_media_profiles()
end end
# NOTE: should move this out of the controller
# once I finally add some query fragment layer
defp total_downloaded_for(source) do
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 defp get_onboarding_layout do
if Settings.get!(:onboarding) do if Settings.get!(:onboarding) do
{Layouts, :onboarding} {Layouts, :onboarding}
@@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do
{"Monthly", 30 * 24 * 60} {"Monthly", 30 * 24 * 60}
] ]
end end
def rss_feed_url(conn, source) do
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end
end end
@@ -2,7 +2,9 @@
<.link href={~p"/sources"}> <.link href={~p"/sources"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" /> <.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link> </.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Source</h2> <h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @source.custom_name %>"
</h2>
</div> </div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1"> <div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
@@ -2,7 +2,7 @@
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2> <h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
<nav> <nav>
<.link href={~p"/sources/new"}> <.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> <span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
</.button> </.button>
</.link> </.link>
@@ -14,19 +14,18 @@
<div class="flex flex-col gap-10 min-w-max"> <div class="flex flex-col gap-10 min-w-max">
<.table rows={@sources} table_class="text-black dark:text-white"> <.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name"> <: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>
<:col :let={source} label="Type"><%= source.collection_type %></:col> <:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Should Download?"> <:col :let={source} label="Should Download?">
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} /> <.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
</:col> </:col>
<:col :let={source} label="Media Profile"> <:col :let={source} label="Media Profile">
<.link <.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
href={~p"/media_profiles/#{source.media_profile_id}"}
class="hover:text-secondary duration-200 ease-in-out"
>
<%= source.media_profile.name %> <%= source.media_profile.name %>
</.link> </.subtle_link>
</:col> </:col>
<:col :let={source} label="" class="flex place-content-evenly"> <:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" /> <.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
@@ -4,13 +4,13 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" /> <.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link> </.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4"> <h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Source <span class="hidden sm:inline">#<%= @source.id %></span> <%= @source.custom_name %>
</h2> </h2>
</div> </div>
<nav> <nav>
<.link href={~p"/sources/#{@source}/edit"}> <.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> <.icon name="hero-pencil-square" class="mr-2" /> Edit <span class="hidden sm:inline pl-1">Source</span>
</.button> </.button>
</.link> </.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="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.tabbed_layout> <.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
copied = true
setTimeout(() => copied = false, 4000)
"}
>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
Delete Source
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Source + Files
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes"> <:tab title="Attributes">
<div class="flex flex-col gap-10 text-white"> <div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-lg">Attributes</h3> <h3 class="font-bold text-lg">Attributes</h3>
@@ -31,28 +71,6 @@
<.list_items_from_map map={Map.from_struct(@source)} /> <.list_items_from_map map={Map.from_struct(@source)} />
</div> </div>
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-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>
<:tab title="Pending Media"> <:tab title="Pending Media">
<%= if match?([_|_], @pending_media) do %> <%= if match?([_|_], @pending_media) do %>
@@ -71,7 +89,7 @@
</:tab> </:tab>
<:tab title="Downloaded Media"> <:tab title="Downloaded Media">
<%= if match?([_|_], @downloaded_media) do %> <%= if match?([_|_], @downloaded_media) do %>
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4> <h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
<.table rows={@downloaded_media} table_class="text-black dark:text-white"> <.table rows={@downloaded_media} table_class="text-black dark:text-white">
<:col :let={media_item} label="Title"> <:col :let={media_item} label="Title">
<%= StringUtils.truncate(media_item.title, 50) %> <%= StringUtils.truncate(media_item.title, 50) %>
@@ -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}> <.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
<h3 class="mt-8 text-2xl text-black dark:text-white"> <section class="flex justify-between items-center mt-8">
General Options <h3 class=" text-2xl text-black dark:text-white">
</h3> 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 <.input
field={f[:custom_name]} field={f[:custom_name]}
@@ -28,24 +39,32 @@
Indexing Options Indexing Options
</h3> </h3>
<.input <section x-data="{ fastIndexingEnabled: null }">
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")}>
<.input <.input
field={f[:fast_index]} field={f[:index_frequency_minutes]}
type="toggle" options={friendly_index_frequencies()}
label="Use Fast Indexing?" type="select"
label_suffix="(pro)" label="Index Frequency"
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info" 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"> <h3 class="mt-8 text-2xl text-black dark:text-white">
Downloading Options Downloading Options
@@ -54,7 +73,7 @@
<.input <.input
field={f[:download_media]} field={f[:download_media]}
type="toggle" type="toggle"
label="Download Media?" label="Download Media"
help="Unchecking still indexes media but it won't be downloaded until you enable this option" 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" 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"> <div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.fast_indexing_help /> <.fast_indexing_help />
+49
View File
@@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do
plug Plug.MethodOverride plug Plug.MethodOverride
plug Plug.Head plug Plug.Head
plug Plug.Session, @session_options plug Plug.Session, @session_options
plug :override_base_url
plug :strip_trailing_extension
plug PinchflatWeb.Router plug PinchflatWeb.Router
# URLs need to be generated using the host of the current page being accessed
# for things like Podcast RSS feeds to contain links to the right location.
#
# Normally you'd set the `url` option in the Endpoint configuration, but
# since this is self-hosted and often accessed at multiple different URLs,
# that would probably be more difficult for end-users to set up than just
# having the application figure it out.
defp override_base_url(conn, _opts) do
new_scheme =
case get_req_header(conn, "x-forwarded-proto") do
[scheme] -> scheme
_ -> to_string(conn.scheme)
end
new_port = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
new_base_url = "#{new_scheme}://#{conn.host}#{new_port}"
Phoenix.Controller.put_router_url(conn, new_base_url)
end
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
defp strip_trailing_extension(conn, _opts) do
path =
conn.path_info
|> List.last()
|> String.split(".")
|> Enum.reverse()
case path do
[_] ->
conn
[_format | fragments] ->
new_path =
fragments
|> Enum.reverse()
|> Enum.join(".")
path_fragments = List.replace_at(conn.path_info, -1, new_path)
%{conn | path_info: path_fragments}
end
end
end end
+24 -4
View File
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.Router do defmodule PinchflatWeb.Router do
use PinchflatWeb, :router use PinchflatWeb, :router
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
# the extension from the path
pipeline :browser do pipeline :browser do
plug :basic_auth plug :basic_auth
plug :accepts, ["html"] plug :accepts, ["html"]
@@ -15,6 +17,10 @@ defmodule PinchflatWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :feeds do
plug :maybe_basic_auth
end
scope "/", PinchflatWeb do scope "/", PinchflatWeb do
pipe_through :browser pipe_through :browser
@@ -28,10 +34,16 @@ defmodule PinchflatWeb.Router do
end end
end end
# Other scopes may use custom stacks. # Routes in here _may not be_ protected by basic auth. This is necessary for
# scope "/api", PinchflatWeb do # media streaming to work for RSS podcast feeds.
# pipe_through :api scope "/", PinchflatWeb do
# end pipe_through :feeds
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
end
# Enable LiveDashboard and Swoosh mailbox preview in development # Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:pinchflat, :dev_routes) do if Application.compile_env(:pinchflat, :dev_routes) do
@@ -50,6 +62,14 @@ defmodule PinchflatWeb.Router do
end end
end end
defp maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end
defp basic_auth(conn, _opts) do defp basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username) username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password) password = Application.get_env(:pinchflat, :basic_auth_password)
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.0-alpha.5", version: "0.1.4",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
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