Compare commits

..

139 Commits

Author SHA1 Message Date
Kieran Eglin 9381c80aac bumped version 2024-04-03 11:10:33 -07:00
Kieran Eglin ce35130a93 Added caching to GH actions 2024-04-03 11:06:40 -07:00
Kieran 3b1c1692fb Retained tab state using location hash (#161) 2024-04-03 11:05:49 -07:00
Kieran 79c61bca4f [Enhancement] Delete media after "x" days (#160)
* [Enhancement] Adds ability to stop media from re-downloading (#159)

* Added column

* Added methods for ignoring media items from future download

* Added new deletion options to controller and UI

* Added controller actions and UI for editing a media item

* Added column to sources

* Added retention period to form

* [WIP] getting retention methods in place

* Hooked up retention worker

* Added column and UI to prevent automatic deletion

* Docs

* Removed unused backfill worker

* Added edit links to media item tabs on source view

* Clarified form wording

* Form wording (again)
2024-04-03 10:44:11 -07:00
Kieran f9c2f7b8f2 [Enhancement] Improve Dockerfile permissions (#157)
* Updated dockerfile

* added a healthcheck
2024-04-01 18:56:03 -07:00
Kieran Eglin 2f9abe86b4 Merged fixes I forgot to push 2024-04-01 18:46:10 -07:00
Kieran 22fbb4b930 Re-adds source uniqueness index (#156) 2024-04-01 18:27:52 -07:00
Kieran Eglin 3daf72a161 Improved index to respect nulls 2024-04-01 18:22:58 -07:00
Kieran Eglin 4e26253b33 Re-adds source uniqueness index 2024-04-01 18:08:46 -07:00
Kieran c58e176619 Added yt-dlp version to sidebar (#155) 2024-04-01 17:54:51 -07:00
Kieran Eglin 0eca0acbe8 Bumped version 2024-03-29 18:20:58 -07:00
Kieran 89f4634d1f [Enhancement] Misc fixes 2024-03-29 (#150)
* Removes header to allow embedding in frame

* Escaped links in podcast RSS builder

* Turned homepage sections into clickable links

* Enabled live dashboard in all ENVs

* Ensured download worker always returns a 2-member tuple
2024-03-29 18:01:09 -07:00
Kieran Eglin 32ac51d7b0 Updated README 2024-03-28 23:05:29 -07:00
Kieran Eglin 198bef71bc Bumped version 2024-03-28 23:02:38 -07:00
Kieran Eglin 54ea8a373a Combined old and new GH actions for docker 2024-03-28 22:40:50 -07:00
Kieran Eglin 0722f43727 Testing conditionals 2024-03-28 22:32:54 -07:00
Kieran 9dd64f3a2d [Housekeeping] Add release workflow (takeover) (#147)
* [Housekeeping] Add release workflow (#141)

* add release workflow

This will create tags when a new release is published

* fix(workflows): refactor release workflow

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

* Update release.yml

---------

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

* Linting

* Testing changes

---------

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

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

* docs

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

* Added sponsorblock options to profile form

* Added SB to download options builder
2024-03-28 19:02:13 -07:00
Kieran 216e05567f trying something new with queries (#142) 2024-03-28 17:59:53 -07:00
Kieran Eglin 2d8b985f13 Bumped version 2024-03-27 20:59:58 -07:00
Kieran d1fe87bdf3 Updated fast indexing to respect title filters (#136) 2024-03-27 19:39:50 -07:00
Kieran Eglin 23d65eaf5f Hopefully fixed select styling 2024-03-27 19:28:25 -07:00
Kieran Eglin 4d6a93d16c Turned GH actions inputs into dropdowns 2024-03-27 19:10:53 -07:00
Kieran Eglin 9985aeca86 Fixed copy not working in insecure contexts 2024-03-27 19:07:15 -07:00
Kieran f38d793f4e Disabled index frequency when fast indexing is enabled (#135) 2024-03-27 18:36:35 -07:00
Kieran 2006f33792 Added total downloaded count to source view (#134) 2024-03-27 18:27:48 -07:00
Kieran 320b25f5b6 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 18:11:25 -07:00
Kieran Eglin d043e5574b Bumped version 2024-03-26 17:55:04 -07:00
Kieran af86fede6c [Bugfix] Expose port in Dockerfile and force IPv4 (#129)
* Exposes port on built image

* Forced IPv4

* Added current version to sidebar
2024-03-26 17:54:20 -07:00
Kieran 7411177e7d Added description to source (#124) 2024-03-25 20:40:09 -07:00
Kieran Eglin 42a418f79e Pushed bug comment 2024-03-25 20:15:21 -07:00
Kieran Eglin c45c030c00 Bumped version 2024-03-25 19:56:31 -07:00
Kieran c08654714a Removed unneeded startup tasks (#121) 2024-03-25 18:52:02 -07:00
Kieran 5c5e7a8a91 Started storing source images in internal metadata folder (#120) 2024-03-25 18:36:03 -07:00
Kieran 1b0d71ea48 Improve default recommendation for artist placeholder (#119)
* Improved default recommendation for audio artist placeholder

* Updated docs
2024-03-25 17:34:22 -07:00
Kieran af803b7081 Fixed filepath bug when parsing metadata (#118) 2024-03-25 17:20:29 -07:00
Kieran fe641cfc49 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 65533b82da Bumped version 2024-03-24 15:42:03 -07:00
Kieran 1923098427 Added GHCR to README (#115) 2024-03-24 15:40:16 -07:00
Kieran Eglin 6ad9e68682 One more GHCR shot 2024-03-24 15:15:43 -07:00
Kieran Eglin 9baaa65c1e Added GHCR tags 2024-03-24 15:13:12 -07:00
Kieran Eglin bd6ea15118 Merge branch 'master' of https://github.com/kieraneglin/pinchflat 2024-03-24 15:07:13 -07:00
Kieran Eglin e92e2001bf Testing GHCR support 2024-03-24 15:07:00 -07:00
Kieran a3d0927dfb 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 54eacf52b3 Added logging for job reset task 2024-03-24 09:28:05 -07:00
Kieran d0f7338f42 Adds a method for retrying stuck jobs (#112) 2024-03-24 09:21:01 -07:00
Kieran Eglin ecc73d25a5 Adds platform option when building docker containers 2024-03-24 09:20:07 -07:00
Kieran Eglin b18c305957 Enabled perf profiler for erlang JIT 2024-03-22 23:34:56 -07:00
Kieran Eglin ccae3366a5 Updated Docker image generation for GH action 2024-03-22 23:15:51 -07:00
Kieran Eglin 182ba7abe7 Out of alpha, into beta 2024-03-22 17:43:38 -07:00
Kieran Eglin f06c02bf62 Bumped version 2024-03-21 17:27:14 -07:00
Kieran 37eb745d03 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 17:26:51 -07:00
Kieran Eglin 66b14c3d8f Updated example worker path in README 2024-03-20 20:05:34 -07:00
Kieran Eglin d9b63875ab Renamed 'music' in README to 'audio content' 2024-03-20 19:28:14 -07:00
Kieran aed2322c63 Improve README (#101)
* Started improving README

* Adds table of contents

* Updated PR template
2024-03-20 19:26:44 -07:00
Kieran Eglin 723cf2a7e9 Bumped version 2024-03-20 18:48:58 -07:00
Kieran b4cdca5206 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 18:34:49 -07:00
Kieran 9310b5e26e 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 18:34:33 -07:00
Kieran 3c522d8d6b 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 17:51:14 -07:00
Kieran Eglin 1328dff7d6 Bumped version; added link to donation receipts 2024-03-19 19:32:59 -07:00
Kieran e2385eccd6 Improve index tables and improved consistency (#97)
* Simplifies index pages

* Improved UX of more pages
2024-03-19 19:03:02 -07:00
Kieran 565a1d4972 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 18:34:01 -07:00
Kieran 05f3deebfa 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 17:27:28 -07:00
Kieran Eglin f91c707c7c Bumped version 2024-03-15 18:35:20 -07:00
Kieran 6e7e8dc96c Misc refactors 2024-03-15 (#90)
* Fixed TV show output template

* Discard jobs if record not found; hopefully addressed timing-related source deletion bug
2024-03-15 18:29:02 -07:00
Kieran b633d0f75c Misc refactors 2024-03-14 (#89)
* Adds method to improve cleanup of empty directories

* resolved bug where source metadata worker could call itself in an infinite loop

* Refactored file deletion for media items

* Removed useless filesystem data worker

* Updated task listing fns to take a record directly

* Refactored the way I call workers

* Improved some tests
2024-03-15 17:44:58 -07:00
Kieran a135746c97 Improve episode-level compatability with media center apps (#86)
* Add media profile presets (#85)

* Added presets for output templates

* Added presets for the entire media profile form

* Append `-thumb` to thumbnails when downloading (#87)

* Appended -thumb to thumbnails when downloading

* Added code to compensate for yt-dlp bug

* Squash all the commits from the other branch bc I broke things (#88)
2024-03-14 18:30:08 -07:00
Kieran c67278ab5c Add support for episode NFO files (#84)
* Added nfo builder for 'episodes'

* Added NFO download fields; hooked up NFO generation to downloading pipeline

* Added NFO option to media profile
2024-03-13 18:31:53 -07:00
Kieran 5bde1205cc More onboarding improvements (#83)
* Improved custom YYYY-MM-DD output template option

* Clarified embedding vs. downloading on media profile form

* Improved form helpers more; Added a helper to every field
2024-03-13 17:51:40 -07:00
Kieran Eglin c1e3ed2128 Bumped version 2024-03-12 20:25:01 -07:00
Kieran 1cc8731914 [Bugfix] Fix reddit bugs v2 (#82)
* Ensured thumbnail is converted to jpg before embedding

* Ensured media indexing doesn't fail if an upload date can't be parsed
2024-03-12 20:23:51 -07:00
Kieran abe99ce6f0 Added input validation to help with cutoff date usage (#80) 2024-03-12 19:31:50 -07:00
Kieran Eglin f506b9bc28 Bumped version 2024-03-12 18:19:06 -07:00
Kieran 5b2d6b094d [Bugfix] Fixes issue with grabbing source/media details when first video is a premier (#79)
* Fixed issue with source details when first video is a premier

* Updated other occurance
2024-03-12 18:18:19 -07:00
Kieran 33baa99aae Refactor modules into contexts (#78)
* [WIP] break out a few contexts, start refactoring fast index modules

* [WIP] more contexts, this time around slow indexing and downloads

* [WIP] got all tests passing

* [WIP] Added moduledocs

* Built a genserver to rename old jobs on boot

* Added a module naming check; moved things around

* Fixed specs
2024-03-12 17:54:55 -07:00
Kieran Eglin 7fa5c4da63 Bumped version 2024-03-11 20:42:18 -07:00
Kieran 993865909c Onboarding improvements (#76)
* Disabled pro modal during onboarding

* Restructured the way onboarding changes settings

* Added better upload date template placeholder
2024-03-11 20:19:10 -07:00
Kieran f156fec4b7 Adds local details to local dockerfile (#75) 2024-03-11 20:05:19 -07:00
Kieran e1fc361e98 Improve audio-only download (#74)
* Improved quality from auto-only download; enables thumbnail and data embedding for audio

* Updated UI to reflect new audio behaviour
2024-03-11 19:33:27 -07:00
Kieran 2267b47de8 Upgrade to Elixir 1.16.2 (#71)
* Upgraded local docker to 1.16.2

* Removed tmpfix from mix check that was waiting for 1.16.2

* Update selfhosted dockerfile with some addl. updates
2024-03-11 18:47:13 -07:00
Kieran e6b04bd675 Converts thumbnails to jpg for better compat (#70) 2024-03-11 18:03:40 -07:00
Kieran Eglin ea4d689fea Bumped version to the correct one LMAO 2024-03-10 21:35:31 -07:00
Kieran Eglin 2f6a19f0e7 Bumped version 2024-03-10 21:33:59 -07:00
Kieran f60ec4f49d Download cutoff date for sources (#69)
* Added new uploaded_at column to media items

* Updated indexer to pull upload date

* Updates media item creation to update on conflict

* Added download cutoff date to sources

* Applies cutoff date logic to pending media logic

* Updated docs
2024-03-10 21:24:01 -07:00
Kieran c0a11e98d9 Updated check origin in release environment (#67) 2024-03-10 15:31:40 -07:00
Kieran 19ec13e64c Local data worker improvements (#66)
* Adds new worker for backfilling data

* Adds backfill job to startup tasks
2024-03-10 15:17:15 -07:00
Kieran e1f8e94686 Fast indexing (#58)
* Made method to getting singular media details; Renamed other related method

* Takes a fun and flirty digression to remove abstractions around yt-dlp since I'm 100% committed to using it exclusively

* Removed commented test code

* Lays the groundwork for fast indexing

* Added module for working with youtube RSS feed

* Added methods to kick off indexing workers from RSS response

* Improve short detection (#59)

* Made media attribute-related yt-dlp calls return a struct

* Added shorts attribute to media items

* Added ability to discern a short from yt-dlp response

* Updated search to use new shorts attribute

* Fast index UI (#63)

* Added fast_index field and adds it to source form

* Added fast indexing to source changeset operations

* Added fast indexing worker and updated other modules to start using it

* Handled fast index worker on source update

* Add support modals (#65)

* Added fast indexing upgrade modal

* Improved modal on smaller screens

* Updated links to work again

* Added donation modal

* Reverted source fast index to 15 minutes

* Removed unneeded HTML attributes from old alpine approach
2024-03-10 14:36:34 -07:00
Kieran 128485c30a Resolved bug where null values in search terms would break search (#57) 2024-03-07 18:42:20 -08:00
Kieran b7beeaf9eb [Bugfix] Runtime log level fix (#56)
* Added log level to runtime config

* Started comparing against an atom instead of a string
2024-03-07 18:26:20 -08:00
Kieran 36daeff48c Added log level to runtime config (#55) 2024-03-07 18:07:00 -08:00
Kieran 0948bebb9d Add settings model (#54)
* Adds a basic settings model

* Added more settings methods; Hooked up initial settings runner to app boot

* Update onboarding flow to use settings model instead of session data
2024-03-07 17:32:12 -08:00
Kieran ef4a5cc99f Added image tagging input to GH actions (#53) 2024-03-04 18:37:15 -08:00
Kieran 82eb12b983 [Bugfix] Fix basic auth in deployed apps (#52)
* Renamed auth env vars and updated them to work in prod env

* Updated basic auth parsing to ignore empty strings
2024-03-04 18:29:26 -08:00
Kieran a4f4afeac9 Updated README (#51) 2024-03-04 18:25:33 -08:00
Kieran 90a0022d85 Rename video-related modules to be media-related (#50)
* Added test from before that I forgor

* Renamed video-related modules to media
2024-03-04 17:46:33 -08:00
Kieran f55cdc80dd Streaming media item creation during indexing (#49)
* Implemented streaming during indexing

* Updated file watcher to enqueue download; refactored download worker methods

* Updated File Follower Server timeout
2024-03-04 17:14:02 -08:00
Kieran b370c97bfb Added way to track media filesize (#48) 2024-03-02 19:19:10 -08:00
Kieran 0f40dcf5dd Clarifies wording of source form for index frequency (#47) 2024-03-02 13:09:58 -08:00
Kieran c37157d644 Support audio only downloads (#46)
* Adds option to only download audio

* Improves helper text for profile options
2024-03-02 13:01:47 -08:00
Kieran 09448e3fcc Source attributes in output template (#45)
* Updated download options to take a media item; allowed for specifying custom output path template options

* Fixed bug where indexing job wouldn't run for the first time

* Renamed friendly_name to custom_name

* Added new options to default template and UI

* Improved explainer UI
2024-03-02 12:15:10 -08:00
Kieran 9e75092876 Pre-prepare for alpha launch (#44)
* Add an initial version

* Add a license
2024-03-01 19:47:19 -08:00
Kieran 36f27c76e8 Useful data on homepage v1 (#43)
* Made the homepage _slightly_ more useful

* Very minor refactor in alpine body-level object
2024-03-01 18:25:55 -08:00
Kieran be8bcf0eb2 Improve relationship UI handling (#42)
* Added tabbed view; improved relationships for media profile

* Adds new maybe_limit method for queries

* Improves UI for media items, associated tasks
2024-02-29 18:35:51 -08:00
Kieran 7809a25f2d Responsive UI improvements (#41)
* Improved responsive UX for media profiles

* Improved responsiveness for other pages

* Added docs; fixed up stragglers
2024-02-29 17:51:54 -08:00
Kieran d2c2cda479 Added some logos up in this (#40) 2024-02-29 17:37:14 -08:00
Kieran 1f4823bda9 UX improvements v1 (#39)
* Removed collection_type user input instead inferring from yt-dlp response

* Updated docs

* Added delete buttons for source; Refactored the way deletion methods work

* Update source to always run an initial index

* Added deletion to the last models

* Improved clarity around deletion operation

* Improved task fetching and deletion methods

* More tests
2024-02-28 18:54:01 -08:00
Kieran 0020e5083b Resolved windows filesystem bug (#38) 2024-02-28 17:56:25 -08:00
Kieran c77047951f Improve metadata storage (#37)
* Updated config path to specify metadata storage path

* Removed metadata column from DB, replacing it with filepath columns

* Updated app to store compressed metadata; automatically download thumbnails

* Ensured metadata is deleted when other files are deleted

* Updated docs

* Added inets to application start so http calls work in prod
2024-02-24 13:40:05 -08:00
Kieran 4094da9e03 Small form improvements (#36)
* Clarified intent of custom name field

* More copy improvements
2024-02-22 18:48:25 -08:00
Kieran 3b94b269d9 Updated video downloader to use full URL; improved docs (#35) 2024-02-22 17:46:08 -08:00
Kieran Eglin 927560beb6 Updated docker build to only run on dispatch 2024-02-22 17:30:09 -08:00
Kieran ab9b657394 Docker build action (round 2) (#34)
* Added first attempt at dockerfile

* Added workflow dispatch [skip ci]

* Trying PR trigger just for now [skip ci]

* Trying some more [skip ci]

* Revert for merge
2024-02-21 18:19:06 -08:00
Kieran 610acb15f1 Added log rotation for prod config (#32) 2024-02-21 18:01:18 -08:00
Kieran 3efc533fb7 Added optional basic auth (#31) 2024-02-21 17:37:25 -08:00
Kieran e3f1b409b9 Improve dev tooling (#30)
* added ex_check and got it passing

* Set up ex_check to run in test env

* Added static code analysis

* Updated actions file

* Updated dockerfile
2024-02-21 17:28:27 -08:00
Kieran 01042ebd1b Updated source's friendly_name to default to the collection_name (#29) 2024-02-20 19:24:47 -08:00
Kieran 1ec1a48235 Refactor and document output path (#28)
* Updated output path builder to better use yt-dlp variables

* Added UI explaining output template options

* Fixed outdated reference in dockerfile
2024-02-20 18:40:13 -08:00
Kieran af7e76c1d1 Rename media sources (#25)
* Renamed MediaSource to Sources

* Renamed MediaItem controller namespace
2024-02-20 17:48:20 -08:00
Kieran 69c47c2c65 Basic onboading flow (#24)
* removed unused file

* [WIP] Created onboarding form

* Finished basic onboarding flow; added tests

* Improved onboarding language
2024-02-20 17:37:17 -08:00
Kieran 1f56db01a5 Prod packaging v1 (#23)
* Sets media directory for prod

* Improved logging; Added docker files for prod

* Improved fetching SECRET_KEY_BASE for selfhosted instances
2024-02-16 18:50:00 -08:00
Kieran b81c8d64b3 Switch to sqlite (#22)
* Updated project to use sqlite

* Edited migrations directly because I fear no man

* Updated search functions and tests to work with sqlite

* Updated build tools to remove postgres
2024-02-16 17:11:41 -08:00
Kieran 53bd8681c3 Add resolution option (#21)
* added preferred resolution to profiles

* Add resolution to download option builder
2024-02-15 18:21:41 -08:00
Kieran c5fcb8fe12 Delete media items (#20)
* Added method for deleting media files and their content

* Adds controllers and methods for deleting media and files

* Improved tmpfile setup and teardown for tests

* Actually got tmpfile cleanup running once per suite run

* Finally fixed flash messages
2024-02-15 17:51:19 -08:00
Kieran 58771ee37b Search v1 (#19)
* Adds description to media items; hooks it up to indexing/media downloading

* Added a search method using postgres fulltext search

* Hooked up search functionality to the search form

* Added persistence to the search form when on search page
2024-02-13 17:46:14 -08:00
Kieran 850cf2db73 Media item lifecycle improvements (#18)
* Added jobs for enqueuing/dequeuing media based on source's status

* adds lifecycle columns to source and media_item tables
2024-02-09 20:51:23 -08:00
Kieran 77f06bfbc8 Media UI v1 (#17)
* Updated comments

* Added basic media tables to sources#show

* Adds a very basic listing and show page for media
2024-02-09 19:21:51 -08:00
Kieran e0637331bb Redo indexing mechanism (#16)
* Bumped up the line length because I fear no man

* Refactored indexing

Previously, indexing worked by collecting the video IDs of only videos
that matched indexing criteria. This new model instead stores ALL videos
for a given source, but will only _download_ videos that meet that criteria.
This lets us backfill without indexing, makes it easier to add in other
backends, lets us download one-off videos for a source that don't quite
meet criteria, you name it.

* Updated media finders to respect format filters; Added credo file
2024-02-09 18:23:37 -08:00
Kieran e1565ad22f First UI Pass (#15)
* Added scratchpad dir

* Installed Alpine

* [WIP] began integrating Tailwind and accompanying theme

* [WIP] Set up basic views for sources

* Adds new UI to media profiles page

* Removes unneeded view
2024-02-08 19:03:11 -08:00
Kieran 9e4fbfa35d Media downloading indexing options (round 2) (#14)
* Adds options + option builder + metadata parsing for media thumbnails

* Added release-type options to media profile; built option parser for indexing operations

* Added new media_profile options to creation form; made show helper for rendering database items

* Added options for downloading/embedding metadata

* Adds option on sources to not download media (index only)

* reformatted docs
2024-02-06 18:36:41 -08:00
Kieran b19c01e3ed Start integrating playlist support (#13)
* [WIP] updated the output of VideoCollection to include playlists

* Updated source's name to collection_name; supported playlist ID/name fetching

* Hooked up collection_type to form; refactored enqueue_pending_media_downloads

* Added friendly_name to form

* Added media profile link to source view

* Updates comment
2024-02-05 17:38:19 -08:00
Kieran 366fd80213 Change Channel to Source (#12)
* Ensure channel detail lookup doesn't download the video - oops

* Ran the needful migrations for replacing channels with sources

* got media source test back working

* channel tasks test

* Media indexing worker test

* media tests

* Got all tests working except controller tests

* got all tests working

* Renamed Channel struct to Source

* renamed ChannelTasks

* More renaming; renamed channel details module

* Removed Channel yt-dlp module, instead throwing things in the VideoCollection module

* Renamed what looks like the last of the outstanding data
2024-02-02 17:45:21 -08:00
Kieran 977b69b7c3 Allow subtitle downloading (#11)
* Added subtitle options to media profile model

* Updated media profile form

* Adds subtitle-based options in options builder

* Updates metadata parser to include subtitles

* Adds subtitle_filepaths to media_item

* renamed video_filepath to media_filepath

* Added more fields to media profile show page
2024-01-31 18:55:02 -08:00
Kieran 4dd9d837a3 Download video(s) (first iteration) (#5)
* Updated package.json (and made an excuse to make a branch)

* Video filepath parser (#6)

* Restructured files; Added parser placeholder

* More restructuring

* Added basic parser for hydrating template strings

* Improved docs

* More docs

* Initial implementation of media profiles (#7)

* [WIP] Added basic video download method

* [WIP] Very-WIP first steps at parsing options and downloading

* Made my options safe by default and removed special safe versions

* Ran html generator for mediaprofile model - leaving as-is for now

* Addressed a bunch of TODO comments

* Add "channel" type Media Source (#8)

* [WIP] Working on fetching channel metadata in yt-dlp backend

* Finished first draft of methods to do with querying channels

* Renamed CommandRunnerMock to have a more descriptive name

* Ran the phx generator for the channel model

* Renamed Downloader namespace to MediaClient

* [WIP] saving before attempting LiveView

* LiveView did not work out but here's a working controller how about

* Index a channel (#9)

* Ran a MediaItem generator; Reformatted to my liking

* [WIP] added basic index function

* setup oban

* Added basic Oban job for indexing

* Added in workers for indexing; hooked them into record creation flow

* Added a task model with a phx generator

* Tied together tasks with jobs and channels

* Download indexed videos (#10)

* Clarified documentation

* more comments

* [WIP] hooked up basic video downloading; starting work on metadata

* Added metadata model and parsing

Adding the metadata model made me realize that, in many cases, yt-dlp
returns undesired input in stdout, breaking parsing. In order to get
the metadata model working, I had to change the way in which the app
interacts with yt-dlp. Now, output is written as a file to disk which
is immediately re-read and returned.

* Added tests for video download worker

* Hooked up video downloading to the channel indexing pipeline

* Adds tasks for media items

* Updated video metadata parser to extract the title

* Ran linting
2024-01-30 20:15:59 -08:00
Kieran 44bec0a127 Add CI (#4)
* Added credo

* Added and ran Prettier; First pass at GH Actions

* Updated actions workflow to hopefully work on this branch

* Name the folder correctly how about

* Added proper container prefix to commands

* Apparently you have to get back into the root after commands

* CI is working, update branches to final value
2024-01-19 23:43:25 -08:00
Kieran 89b1640eb6 Initial yt-dlp implementation (#3)
* Added basic runner for yt-dlp commands

* Updated formatting rules

* Added JSON runner; Added test script for CLI interactions

* Reworked yt-dlp runner; added proper test mocks
2024-01-19 22:26:32 -08:00
Kieran c03fd837ea Initial setup (first pass) (#1)
* Updated Elixir version; made phx interactive

* Added JS formatting rules

* Added yt-dlp to dockerfile

* Added a basic PR template

* Added default responses for the PR template
2024-01-19 17:49:16 -08:00
104 changed files with 12834 additions and 730 deletions
@@ -1,32 +0,0 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker Image Tag'
required: true
default: 'dev'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
file: ./selfhosted.Dockerfile
tags: 'keglin/pinchflat:${{ github.event.inputs.image_tag }}'
+81
View File
@@ -0,0 +1,81 @@
name: Docker Release
on:
workflow_dispatch:
inputs:
platforms:
type: choice
description: 'Build Platforms'
required: true
default: 'linux/amd64'
options:
- 'linux/amd64'
- 'linux/amd64,linux/arm64'
push:
branches:
- master
release:
types:
- published
jobs:
build_and_push_docker:
runs-on: ubuntu-latest
env:
dev_arch: 'linux/amd64'
release_arch: 'linux/amd64,linux/arm64'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
docker.io/keglin/pinchflat
ghcr.io/${{ github.repository }}
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
flavor: |
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
file: ./selfhosted.Dockerfile
# If the event is a release, use the release_arch, otherwise use the
# platforms input if present, falling back to dev_arch
platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -27
View File
@@ -22,30 +22,4 @@ alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer
defmodule IexHelpers do
def last_media_item do
Repo.one(from m in MediaItem, limit: 1)
end
def details(type) do
source =
case type do
:playlist -> playlist_url()
:channel -> channel_url()
end
YtDlpCollection.get_source_details(source)
end
def ids(type) do
source =
case type do
:playlist -> playlist_url()
:channel -> channel_url()
end
YtDlpCollection.get_media_attributes_for_collection(source)
end
end
import IexHelpers
Pinchflat.Release.check_file_permissions()
+9 -1
View File
@@ -9,7 +9,15 @@
threshold: :low,
# All of these are ignorable because this app is intended to be single-user and self-hosted.
# There is an expectation that the user won't intentionally run a FS Traversal on themselves
ignore: ["CI.System", "Traversal.FileModule", "Config.HTTPS", "Config.CSP"],
ignore:
[
"CI.System",
"Traversal.FileModule",
"Config.HTTPS",
"Config.CSP",
"XSS.ContentType",
"Traversal.SendFile"
],
ignore_files: [],
version: false
]
+39 -32
View File
@@ -22,12 +22,12 @@
- [Installation](#installation)
- [Unraid](#unraid)
- [Docker](#docker)
- [Authentication](#authentication)
- [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password)
- [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions)
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
- [EFF donations](#eff-donations)
- [Pre-release disclaimer](#pre-release-disclaimer)
- [Development](#development)
- [Development and Contributing](https://github.com/kieraneglin/pinchflat/wiki/Development-and-Contributing)
## What it does
@@ -43,12 +43,16 @@ If it doesn't work for your use case, please make a feature request! You can als
- Powerful naming system so content is stored where and how you want it
- 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
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
- Advanced options like setting cutoff dates and filtering by title
- Reliable hands-off operation
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
- Sponsorblock integration
## Screenshots
@@ -61,12 +65,31 @@ If it doesn't work for your use case, please make a feature request! You can als
Simply search for Pinchflat in the Community Apps store!
### Portainer
Important: See the note below about storing config on a network file share. It's preferred to store the config on a local disk if at all possible.
Docker Compose file:
```yaml
version: '3'
services:
pinchflat:
image: keglin/pinchflat:latest
ports:
- '8945:8945'
volumes:
- /host/path/to/config:/config
- /host/path/to/downloads:/downloads
```
### Docker
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 Docker Hub:** `docker pull keglin/pinchflat:latest`, or;
- **Building locally:** `docker build . --file selfhosted.Dockerfile -t keglin/pinchflat:latest`
- **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
@@ -76,14 +99,22 @@ docker run \
-p 8945:8945 \
-v /host/path/to/config:/config \
-v /host/path/to/downloads:/downloads \
keglin/pinchflat:latest
ghcr.io/kieraneglin/pinchflat:latest
```
NOTE: 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.
### IMPORTANT: File permissions
## Authentication
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.
HTTP basic authentication is optionally supported. To use it, set the `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables when starting the container. No authentication will be required unless you set _both_ of these.
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.
### Advanced: storing Pinchflat config directory on a network share
README: This is currently in the testing phase and not a recommended option (yet). The implications of changing this setting isn't clear and this could, conceivably, result in data loss. Only change this setting if you know what you're doing, why this is important, and are okay with possible data loss or DB corruption. This may become the default in the future once it's been tested more thoroughly.
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well.
## EFF donations
@@ -93,30 +124,6 @@ A portion of all donations to Pinchflat will be donated to the [Electronic Front
This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now.
## Development
Pinchflat is written in Elixir - a functional programming language that runs on the Erlang VM. It uses the Phoenix web framework and SQLite for the database. The frontend is mostly normal server-rendered HTML with a little Alpine.js as-needed.
Elixir is a personal favourite of mine and is ideal for building fault-tolerant systems. It's also a joy to work with and has a great community. If you're interested in contributing, I'd be happy to help you get started with Elixir - just open an issue with some questions and we can chat!
### Local setup
- `docker compose build --no-cache`
- `docker compose up -d && docker attach pinchflat-phx-1`
- After a few minutes the app should be accessible at `localhost:4008`
- Please let me know if you run into any hiccups here - I haven't had to bootstrap the app from scratch in a long time and I might have forgotten something
- Media downloads and config will be stored in the `tmp` directory. Not the OS's `/tmp` directory, but the one in the root of the project
### Running tests and linting
- Open a shell with `docker compose exec phx bash`
- Run `mix test` to run the tests
- Run `mix check` to do a full testing, linting, and static analysis pass
### Top tips
- Look for any module that ends in `*_worker.ex` - these are where the interesting stuff happens and you can trace back from there to see how the app works. `lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex` is a good place to start
## License
See `LICENSE` file
+25
View File
@@ -22,6 +22,7 @@ import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import topbar from '../vendor/topbar'
import Alpine from 'alpinejs'
import './tabs'
window.Alpine = Alpine
Alpine.start()
@@ -38,6 +39,30 @@ let liveSocket = new LiveSocket('/live', Socket, {
}
})
window.copyTextToClipboard = async (text) => {
// Navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const textArea = document.createElement('textarea')
textArea.value = text
// Move textarea out of the viewport so it's not visible
textArea.style.position = 'absolute'
textArea.style.left = '-999999px'
document.body.prepend(textArea)
textArea.select()
try {
document.execCommand('copy')
} catch (error) {
console.error(error)
} finally {
textArea.remove()
}
}
}
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300))
+20
View File
@@ -0,0 +1,20 @@
window.setTabIndex = (index) => {
window.location.hash = `tab-${index}`
return index
}
// The conditionals and currIndex stuff ensures that
// the tab index is always set to 0 if the hash is empty
// AND other hash values are ignored
window.getTabIndex = (currIndex) => {
if (window.location.hash === '' || window.location.hash === '#') {
return 0
}
if (window.location.hash.startsWith('#tab-')) {
return parseInt(window.location.hash.replace('#tab-', ''))
}
return currIndex
}
+15 -3
View File
@@ -16,13 +16,19 @@ config :pinchflat,
media_directory: "/downloads",
# The user may or may not store metadata for their needs, but the app will always store its copy
metadata_directory: "/config/metadata",
extras_directory: "/config/extras",
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
# Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth.
# If either is unset, basic auth will not be used.
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"),
basic_auth_username: "",
basic_auth_password: "",
expose_feed_endpoints: false,
file_watcher_poll_interval: 1000
config :pinchflat, Pinchflat.Repo,
journal_mode: :wal,
pool_size: 5
# Configures the endpoint
config :pinchflat, PinchflatWeb.Endpoint,
url: [host: "localhost", port: 8945],
@@ -40,7 +46,13 @@ config :pinchflat, Oban,
engine: Oban.Engines.Lite,
repo: Pinchflat.Repo,
# Keep old jobs for 30 days for display in the UI
plugins: [{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60}],
plugins: [
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
{Oban.Plugins.Cron,
crontab: [
{"@daily", Pinchflat.Downloading.MediaRetentionWorker}
]}
],
# TODO: consider making this an env var or something?
queues: [
default: 10,
+1
View File
@@ -3,6 +3,7 @@ import Config
config :pinchflat,
media_directory: Path.join([File.cwd!(), "tmp", "media"]),
metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"]),
extras_directory: Path.join([File.cwd!(), "tmp", "extras"]),
tmpfile_directory: Path.join([File.cwd!(), "tmp", "tmpfiles"])
# Configure your database
+15 -69
View File
@@ -41,36 +41,32 @@ config :pinchflat, Pinchflat.Repo,
]
if config_env() == :prod do
config_path =
System.get_env("CONFIG_PATH") ||
raise """
environment variable CONFIG_PATH is missing.
For example: /etc/pinchflat/config
"""
config_path = "/config"
db_path = System.get_env("DATABASE_PATH", Path.join([config_path, "db", "pinchflat.db"]))
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
# We want to force _some_ level of useful logging in production
acceptable_log_levels = ~w(debug info)a
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
# For running PF as a podcast host on self-hosted environments
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
if log_level in acceptable_log_levels do
config :logger, level: log_level
else
Logger.error("Invalid log level: #{log_level}. Defaulting to info.")
config :logger, level: :info
end
# For testing alternate journal modes (see issue #137)
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"),
media_directory: "/downloads",
metadata_directory: metadata_path,
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY")
extras_directory: extras_path,
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints
config :pinchflat, Pinchflat.Repo,
database: db_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
journal_mode: journal_mode
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
@@ -104,7 +100,7 @@ if config_env() == :prod do
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
ip: {0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000")
],
secret_key_base: secret_key_base
@@ -123,54 +119,4 @@ if config_env() == :prod do
formatter: Logger.Formatter.new()
}}
]
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :pinchflat, PinchflatWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :pinchflat, PinchflatWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :pinchflat, Pinchflat.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end
+1 -3
View File
@@ -17,7 +17,6 @@ config :pinchflat, Oban, testing: :manual
# Run `mix help test` for more information.
config :pinchflat, Pinchflat.Repo,
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required,
@@ -33,8 +32,7 @@ config :pinchflat, Pinchflat.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
config :logger, level: :critical
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
@@ -1,75 +0,0 @@
defmodule Pinchflat.Boot.DataBackfillWorker do
@moduledoc false
use Oban.Worker,
queue: :local_metadata,
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
# This one is going to be a little more self-contained
# instead of relying on outside modules for the methods.
# That's because, for now, these methods are not intended
# to be used elsewhere.
#
# I'm just trying out that pattern and seeing if I like it better
# so this may change.
import Ecto.Query, warn: false
require Logger
alias __MODULE__
alias Pinchflat.Repo
alias Pinchflat.Media.MediaItem
@doc """
Cancels all pending backfill jobs. Useful for ensuring worker runs immediately
on app boot.
Returns {:ok, integer()}
"""
def cancel_pending_backfill_jobs do
Oban.Job
|> where(worker: "Pinchflat.Boot.DataBackfillWorker")
|> Oban.cancel_all_jobs()
end
@impl Oban.Worker
@doc """
Performs one-off tasks to get data in the right shape.
This can be needed when we add new features or change the way
we store data. Must be idempotent. All new data should already
conform to the expected schema so this should only be needed
for existing data. Still runs periodically to be safe.
Returns :ok
"""
def perform(%Oban.Job{}) do
Logger.info("Running data backfill worker")
backfill_shorts_data()
reschedule_backfill()
:ok
end
defp backfill_shorts_data do
query =
from(
m in MediaItem,
where: fragment("? like ?", m.original_url, "%/shorts/%"),
where: m.short_form_content == false
)
{count, _} = Repo.update_all(query, set: [short_form_content: true])
Logger.info("Backfill worker set short_form_content to true for #{count} media items.")
end
defp reschedule_backfill do
# Run hourly
next_run_in = 60 * 60
%{}
|> DataBackfillWorker.new(schedule_in: next_run_in)
|> Repo.insert_unique_job()
end
end
+1 -13
View File
@@ -11,9 +11,6 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
use GenServer, restart: :temporary
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Boot.DataBackfillWorker
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts)
end
@@ -29,16 +26,7 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
"""
@impl true
def init(state) do
enqueue_backfill_worker()
# Empty for now, keeping because tasks _will_ be added in future
{:ok, state}
end
defp enqueue_backfill_worker do
DataBackfillWorker.cancel_pending_backfill_jobs()
%{}
|> DataBackfillWorker.new()
|> Repo.insert_unique_job()
end
end
+29 -46
View File
@@ -14,6 +14,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Repo
alias Pinchflat.Settings
alias Pinchflat.YtDlp.CommandRunner
alias Pinchflat.Filesystem.FilesystemHelpers
def start_link(opts \\ []) do
@@ -31,59 +32,41 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
"""
@impl true
def init(state) do
reset_executing_jobs()
create_blank_cookie_file()
apply_default_settings()
ensure_directories_are_writeable()
rename_old_job_workers()
{:ok, state}
end
# If a node cannot gracefully shut down, the currently executing jobs get stuck
# in the "executing" state. This is a problem because the job runner will not
# pick them up again
defp reset_executing_jobs do
{count, _} =
Oban.Job
|> where(state: "executing")
|> Repo.update_all(set: [state: "retryable"])
Logger.info("Reset #{count} executing jobs")
end
defp create_blank_cookie_file do
base_dir = Application.get_env(:pinchflat, :extras_directory)
filepath = Path.join(base_dir, "cookies.txt")
if !File.exists?(filepath) do
Logger.info("Cookies does not exist - creating it")
FilesystemHelpers.write_p!(filepath, "")
end
end
defp apply_default_settings do
{:ok, yt_dlp_version} = CommandRunner.version()
Settings.fetch!(:onboarding, true)
Settings.fetch!(:pro_enabled, false)
end
defp ensure_directories_are_writeable do
directories = [
Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :metadata_directory)
]
Enum.each(directories, fn dir ->
file = Path.join([dir, ".keep"])
# This will fail if the directory is not writeable, stopping boot
FilesystemHelpers.write_p!(file, "")
end)
end
# As part of a large refactor, I ended up moving a bunch of workers around. This
# is a problem because the workers are stored in the database and the runner
# will try to run the OLD jobs. This is also why these tasks run before the job
# runner starts up.
#
# Can be removed after a few months (created: 2024-03-12)
defp rename_old_job_workers do
# [ [old_name, new_name], ...]
rename_map = [
["Pinchflat.Workers.MediaIndexingWorker", "Pinchflat.FastIndexing.MediaIndexingWorker"],
["Pinchflat.Workers.MediaDownloadWorker", "Pinchflat.Downloading.MediaDownloadWorker"],
["Pinchflat.Workers.FastIndexingWorker", "Pinchflat.FastIndexing.FastIndexingWorker"],
["Pinchflat.Workers.MediaCollectionIndexingWorker", "Pinchflat.SlowIndexing.MediaCollectionIndexingWorker"],
["Pinchflat.Workers.DataBackfillWorker", "Pinchflat.Boot.DataBackfillWorker"]
]
jobs_renamed =
Enum.reduce(rename_map, 0, fn [old_name, new_name], acc ->
{count, _} =
Oban.Job
|> where(worker: ^old_name)
|> Repo.update_all(set: [worker: new_name])
acc + count
end)
Logger.info("Renamed #{jobs_renamed} old job workers")
Settings.set!(:yt_dlp_version, yt_dlp_version)
end
end
@@ -22,6 +22,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
thumbnail_options(media_item_with_preloads) ++
metadata_options(media_profile) ++
quality_options(media_profile) ++
sponsorblock_options(media_profile) ++
output_options(media_item_with_preloads)
{:ok, built_options}
@@ -116,6 +117,17 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end
end
defp sponsorblock_options(media_profile) do
categories = media_profile.sponsorblock_categories
behaviour = media_profile.sponsorblock_behaviour
case {behaviour, categories} do
{_, []} -> []
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
{:disabled, _} -> []
end
end
defp output_options(media_item_with_preloads) do
[
output: build_output_path_for(media_item_with_preloads.source)
@@ -57,7 +57,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
{:ok, updated_media_item}
err ->
err
Logger.error("Failed to download media for media item #{media_item.id}: #{inspect(err)}")
{:error, :download_failed}
end
end
@@ -0,0 +1,33 @@
defmodule Pinchflat.Downloading.MediaRetentionWorker do
@moduledoc false
use Oban.Worker,
queue: :local_metadata,
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "local_metadata"]
require Logger
alias Pinchflat.Media
@doc """
Deletes media items that are past their retention date and prevents
them from being re-downloaded.
This worker is scheduled to run daily via the Oban Cron plugin.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
cullable_media = Media.list_cullable_media_items()
Logger.info("Culling #{length(cullable_media)} media items past their retention date")
Enum.each(cullable_media, fn media_item ->
Media.delete_media_files(media_item, %{
prevent_download: true,
culled_at: DateTime.utc_now()
})
end)
end
end
@@ -42,7 +42,8 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
"upload_day" => "%(upload_date>%d)S",
"upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S",
"season_from_date" => "%(upload_date>%Y)S",
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S"
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S",
"artist_name" => "%(artist,creator,uploader,uploader_id)S"
}
end
end
@@ -5,8 +5,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
Many of these methods are made to be kickoff or be consumed by workers.
"""
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.MediaIndexingWorker
@@ -27,7 +29,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
"""
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
existing_media_items = Media.list_media_items_by_media_id_for(source, media_ids)
existing_media_items = list_media_items_by_media_id_for(source, media_ids)
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
Enum.each(new_media_ids, fn media_id ->
@@ -61,6 +63,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
end
end
defp list_media_items_by_media_id_for(source, media_ids) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_ids(media_ids)
|> Repo.all()
end
defp create_media_item_from_url(source, url) do
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
+16 -5
View File
@@ -20,6 +20,21 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
filepath
end
@doc """
Writes content to a file, creating directories as needed.
Takes the same args as File.write/3.
Returns :ok | {:error, any()}
"""
def write_p(file, content, modes \\ []) do
dirname = Path.dirname(file)
case File.mkdir_p(dirname) do
:ok -> File.write(file, content, modes)
err -> err
end
end
@doc """
Writes content to a file, creating directories as needed.
Takes the same args as File.write!/3.
@@ -27,11 +42,7 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
Returns :ok | raises on error
"""
def write_p!(filepath, content, modes \\ []) do
filepath
|> Path.dirname()
|> File.mkdir_p!()
File.write!(filepath, content, modes)
:ok = write_p(filepath, content, modes)
end
@doc """
+53 -100
View File
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
alias Pinchflat.Tasks
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Filesystem.FilesystemHelpers
@@ -22,29 +23,17 @@ defmodule Pinchflat.Media do
end
@doc """
Returns a list of media_items for a given source.
Returns a list of media_items that are cullable based on the retention period
of the source they belong to.
Returns [%MediaItem{}, ...].
Returns [%MediaItem{}, ...]
"""
def list_media_items_for(%Source{} = source) do
MediaItem
|> where([mi], mi.source_id == ^source.id)
|> Repo.all()
end
@doc """
Fetches all media items belonging to a given source that have a media_id in the given list.
Useful for determining the what media items we DON'T already have for fast indexing.
NOTE: These queries are getting a little tedious. When I have the time, I should see about
implementing a query pattern and having these compose queries from a common base. This would
also let me compose simple queries in the module using them for one-off methods
Returns [%MediaItem{}, ...].
"""
def list_media_items_by_media_id_for(%Source{} = source, media_ids) do
MediaItem
|> where([mi], mi.source_id == ^source.id and mi.media_id in ^media_ids)
def list_cullable_media_items do
MediaQuery.new()
|> MediaQuery.join_sources()
|> MediaQuery.with_media_filepath()
|> MediaQuery.with_passed_retention_period()
|> MediaQuery.with_no_culling_prevention()
|> Repo.all()
end
@@ -53,20 +42,18 @@ defmodule Pinchflat.Media do
pending means the `media_filepath` is `nil` AND the media_item
matches the format selection rules of the parent media_profile.
See `build_format_clauses` but tl;dr is it _may_ filter based
on shorts or livestreams depending on the media_profile settings.
See `matching_download_criteria_for` but tl;dr is it _may_ filter based
on shorts livestreams depending on the media_profile settings.
Returns [%MediaItem{}, ...].
"""
def list_pending_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil)
media_profile = Repo.preload(source, :media_profile).media_profile
source = Repo.preload(source, :media_profile)
MediaItem
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile))
|> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
MediaQuery.new()
|> MediaQuery.for_source(source)
|> matching_download_criteria_for(source)
|> Repo.maybe_limit(limit)
|> Repo.all()
end
@@ -79,8 +66,9 @@ defmodule Pinchflat.Media do
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil)
MediaItem
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath))
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.maybe_limit(limit)
|> Repo.all()
end
@@ -97,10 +85,9 @@ defmodule Pinchflat.Media do
def pending_download?(%MediaItem{} = media_item) do
media_item = Repo.preload(media_item, source: :media_profile)
MediaItem
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_item.source.media_profile))
|> where(^maybe_apply_cutoff_date(media_item.source))
MediaQuery.new()
|> MediaQuery.with_id(media_item.id)
|> matching_download_criteria_for(media_item.source)
|> Repo.exists?()
end
@@ -119,20 +106,9 @@ defmodule Pinchflat.Media do
def search(search_term, opts) do
limit = Keyword.get(opts, :limit, 50)
from(mi in MediaItem,
join: mi_search_index in assoc(mi, :media_items_search_index),
where: fragment("media_items_search_index MATCH ?", ^search_term),
select_merge: %{
matching_search_term:
fragment("""
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
' ' ||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
""")
},
order_by: [desc: fragment("rank")],
limit: ^limit
)
MediaQuery.new()
|> MediaQuery.matching_search_term(search_term)
|> Repo.maybe_limit(limit)
|> Repo.all()
end
@@ -200,7 +176,7 @@ defmodule Pinchflat.Media do
Tasks.delete_tasks_for(media_item)
if delete_files do
{:ok, _} = delete_media_files(media_item)
{:ok, _} = do_delete_media_files(media_item)
end
# Should delete these no matter what
@@ -208,6 +184,25 @@ defmodule Pinchflat.Media do
Repo.delete(media_item)
end
@doc """
Deletes the tasks and media files associated with a media_item but leaves the
media_item in the database. Does not delete anything to do with associated metadata.
Optionally accepts a second argument `addl_attrs` which will be merged into the
media_item before it is updated. Useful for setting things like `prevent_download`
and `culled_at`, if wanted
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
"""
def delete_media_files(%MediaItem{} = media_item, addl_attrs \\ %{}) do
filepath_attrs = MediaItem.filepath_attribute_defaults()
Tasks.delete_tasks_for(media_item)
{:ok, _} = do_delete_media_files(media_item)
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
"""
@@ -215,7 +210,7 @@ defmodule Pinchflat.Media do
MediaItem.changeset(media_item, attrs)
end
defp delete_media_files(media_item) do
defp do_delete_media_files(media_item) do
mapped_struct = Map.from_struct(media_item)
MediaItem.filepath_attributes()
@@ -240,54 +235,12 @@ defmodule Pinchflat.Media do
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end
defp maybe_apply_cutoff_date(source) do
if source.download_cutoff_date do
dynamic([mi], mi.upload_date >= ^source.download_cutoff_date)
else
dynamic(true)
end
end
defp maybe_apply_title_regex(source) do
if source.title_filter_regex do
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
else
dynamic(true)
end
end
defp build_format_clauses(media_profile) do
mapped_struct = Map.from_struct(media_profile)
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
case {attr, media_profile} do
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
dynamic(
[mi],
^dynamic and (mi.livestream == true or mi.short_form_content == true)
)
# Technically redundant, but makes the other clauses easier to parse
# (redundant because this condition is the same as the condition above, just flipped)
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
dynamic
{{:shorts_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.short_form_content == true)
{{:livestream_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.livestream == true)
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
dynamic([mi], ^dynamic and mi.short_form_content == false)
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
# return records with livestream: false
dynamic([mi], ^dynamic and mi.livestream == false)
_ ->
dynamic
end
end)
defp matching_download_criteria_for(query, source_with_preloads) do
query
|> MediaQuery.with_no_prevented_download()
|> MediaQuery.with_no_media_filepath()
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
|> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
end
end
+30 -2
View File
@@ -5,6 +5,7 @@ defmodule Pinchflat.Media.MediaItem do
use Ecto.Schema
import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils
alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source
@@ -21,6 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
:source_id,
:short_form_content,
:upload_date,
:duration_seconds,
# these fields are captured only on download
:media_downloaded_at,
:media_filepath,
@@ -28,10 +30,15 @@ defmodule Pinchflat.Media.MediaItem do
:subtitle_filepaths,
:thumbnail_filepath,
:metadata_filepath,
:nfo_filepath
:nfo_filepath,
# These are user or system controlled fields
:prevent_download,
:prevent_culling,
:culled_at
]
# Pretty much all the fields captured at index are required.
@required_fields ~w(
uuid
title
original_url
livestream
@@ -39,9 +46,14 @@ defmodule Pinchflat.Media.MediaItem do
source_id
upload_date
short_form_content
)a
)a
schema "media_items" do
# This is _not_ used as the primary key or internally in the database
# relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :title, :string
field :media_id, :string
field :description, :string
@@ -50,6 +62,7 @@ defmodule Pinchflat.Media.MediaItem do
field :short_form_content, :boolean, default: false
field :media_downloaded_at, :utc_datetime
field :upload_date, :date
field :duration_seconds, :integer
field :media_filepath, :string
field :media_size_bytes, :integer
@@ -61,6 +74,10 @@ defmodule Pinchflat.Media.MediaItem do
# Will very likely revisit because I can't leave well-enough alone.
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
field :prevent_download, :boolean, default: false
field :prevent_culling, :boolean, default: false
field :culled_at, :utc_datetime
field :matching_search_term, :string, virtual: true
belongs_to :source, Source
@@ -78,6 +95,7 @@ defmodule Pinchflat.Media.MediaItem do
media_item
|> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(@required_fields)
|> unique_constraint([:media_id, :source_id])
end
@@ -86,4 +104,14 @@ defmodule Pinchflat.Media.MediaItem do
def filepath_attributes do
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
end
@doc false
def filepath_attribute_defaults do
filepath_attributes()
|> Enum.map(fn
:subtitle_filepaths -> {:subtitle_filepaths, []}
field -> {field, nil}
end)
|> Enum.into(%{})
end
end
+146
View File
@@ -0,0 +1,146 @@
defmodule Pinchflat.Media.MediaQuery do
@moduledoc """
Query helpers for the Media context.
These methods are made to be one-ish liners used
to compose queries for media items. Each method should
strive to do _one_ thing. These don't need to be tested
as they are just building blocks for other functionality
which, itself, will be tested.
ALSO, this is me trying something new. If I like it,
I'll refactor other contexts to use this pattern.
"""
import Ecto.Query, warn: false
alias Pinchflat.Media.MediaItem
# Prefixes:
# - for_* - belonging to a certain record
# - join_* - for joining on a certain record
# - with_* - for filtering based on full, concrete attributes
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
#
# Suffixes:
# - _for - the arg passed is an association record
def new do
MediaItem
end
def for_source(query, source) do
where(query, [mi], mi.source_id == ^source.id)
end
def join_sources(query) do
from(mi in query, join: s in assoc(mi, :source), as: :sources)
end
def with_passed_retention_period(query) do
where(
query,
[mi, sources],
fragment(
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
sources.retention_period_days,
sources.retention_period_days,
mi.media_downloaded_at
)
)
end
def with_no_culling_prevention(query) do
where(query, [mi], mi.prevent_culling == false)
end
def with_id(query, id) do
where(query, [mi], mi.id == ^id)
end
def with_media_ids(query, media_ids) do
where(query, [mi], mi.media_id in ^media_ids)
end
def with_media_filepath(query) do
where(query, [mi], not is_nil(mi.media_filepath))
end
def with_no_media_filepath(query) do
where(query, [mi], is_nil(mi.media_filepath))
end
def with_upload_date_after(query, nil), do: query
def with_upload_date_after(query, date) do
where(query, [mi], mi.upload_date >= ^date)
end
def with_no_prevented_download(query) do
where(query, [mi], mi.prevent_download == false)
end
def matching_title_regex(query, nil), do: query
def matching_title_regex(query, regex) do
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^regex))
end
def matching_search_term(query, nil), do: query
def matching_search_term(query, term) do
from(mi in query,
join: mi_search_index in assoc(mi, :media_items_search_index),
where: fragment("media_items_search_index MATCH ?", ^term),
select_merge: %{
matching_search_term:
fragment("""
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
' ' ||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
""")
},
order_by: [desc: fragment("rank")]
)
end
# NOTE: this method breaks the contract set by other methods in that it
# takes a media_profile struct instead of taking just the attributes it
# cares about. Consider refactoring but low priority.
def with_format_preference(query, media_profile) do
mapped_struct = Map.from_struct(media_profile)
finders =
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
case {attr, media_profile} do
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
dynamic(
[mi],
^dynamic and (mi.livestream == true or mi.short_form_content == true)
)
# Technically redundant, but makes the other clauses easier to parse
# (redundant because this condition is the same as the condition above, just flipped)
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
dynamic
{{:shorts_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.short_form_content == true)
{{:livestream_behaviour, :only}, _} ->
dynamic([mi], ^dynamic and mi.livestream == true)
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
dynamic([mi], ^dynamic and mi.short_form_content == false)
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
# return records with livestream: false
dynamic([mi], ^dynamic and mi.livestream == false)
_ ->
dynamic
end
end)
where(query, ^finders)
end
end
@@ -11,6 +11,22 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
alias Pinchflat.Filesystem.FilesystemHelpers
@doc """
Returns the directory where metadata for a database record should be stored.
Returns binary()
"""
def metadata_directory_for(database_record) do
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
record_table_name = database_record.__meta__.source
Path.join([
metadata_directory,
record_table_name,
to_string(database_record.id)
])
end
@doc """
Compresses and stores metadata for a media item, returning the filepath.
@@ -108,13 +124,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
end
defp generate_filepath_for(database_record, filename) do
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
record_table_name = database_record.__meta__.source
Path.join([
metadata_directory,
record_table_name,
to_string(database_record.id),
metadata_directory_for(database_record),
filename
])
end
+24 -8
View File
@@ -30,7 +30,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
original_url: metadata["original_url"],
description: metadata["description"],
media_filepath: metadata["filepath"],
livestream: metadata["was_live"]
livestream: metadata["was_live"],
duration_seconds: metadata["duration"] && round(metadata["duration"])
}
end
@@ -58,23 +59,38 @@ defmodule Pinchflat.Metadata.MetadataParser do
# NOTE: whole ordeal needed due to a bug I found in yt-dlp
# https://github.com/yt-dlp/yt-dlp/issues/9445
# Can be reverted to remove this entire conditional once fixed
filepath =
thumbnail_filepath
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
%{
thumbnail_filepath:
thumbnail_filepath
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
thumbnail_filepath: filepath_if_exists(filepath)
}
else
%{
thumbnail_filepath: thumbnail_filepath
thumbnail_filepath: nil
}
end
end
defp parse_infojson_metadata(metadata) do
%{
metadata_filepath: metadata["infojson_filename"]
metadata_filepath: filepath_if_exists(metadata["infojson_filename"])
}
end
# NOTE: this should not be needed, but it is due to a bug in yt-dlp.
# Can remove once this is resolved:
# https://github.com/yt-dlp/yt-dlp/issues/9445#issuecomment-2018724344
defp filepath_if_exists(nil), do: nil
defp filepath_if_exists(filepath) do
if File.exists?(filepath) do
filepath
else
nil
end
end
end
+16 -2
View File
@@ -10,11 +10,20 @@ defmodule Pinchflat.Metadata.SourceMetadata do
alias Pinchflat.Sources.Source
@allowed_fields ~w(metadata_filepath)a
@allowed_fields ~w(
metadata_filepath
fanart_filepath
poster_filepath
banner_filepath
)a
@required_fields ~w(metadata_filepath)a
schema "source_metadata" do
field :metadata_filepath, :string
field :fanart_filepath, :string
field :poster_filepath, :string
field :banner_filepath, :string
belongs_to :source, Source
@@ -31,6 +40,11 @@ defmodule Pinchflat.Metadata.SourceMetadata do
@doc false
def filepath_attributes do
~w(metadata_filepath)a
~w(
metadata_filepath
fanart_filepath
poster_filepath
banner_filepath
)a
end
end
@@ -32,6 +32,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
@doc """
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)
@@ -47,21 +48,24 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
def perform(%Oban.Job{args: %{"id" => source_id}}) do
source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile])
series_directory = determine_series_directory(source)
{source_metadata, image_filepath_attrs} = fetch_source_metadata_and_images(series_directory, source)
# `run_post_commit_tasks: false` prevents this from running in an infinite loop
{source_metadata, source_image_attrs, metadata_image_attrs} =
fetch_source_metadata_and_images(series_directory, source)
source_metadata_filepath = MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata)
Sources.update_source(
source,
Map.merge(
%{
series_directory: series_directory,
nfo_filepath: store_source_nfo(source, series_directory, source_metadata),
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata)
}
description: source_metadata["description"],
metadata: Map.merge(%{metadata_filepath: source_metadata_filepath}, metadata_image_attrs)
},
image_filepath_attrs
source_image_attrs
),
# `run_post_commit_tasks: false` prevents this from running in an infinite loop
run_post_commit_tasks: false
)
@@ -72,18 +76,19 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
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
output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: output_path]
source_image_attrs = SourceImageParser.store_source_images(series_directory, metadata)
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
image_attrs = SourceImageParser.store_source_images(series_directory, metadata)
{metadata, image_attrs}
{metadata, source_image_attrs, metadata_image_attrs}
else
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, [])
{metadata, %{}}
{metadata, %{}, metadata_image_attrs}
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
+139
View File
@@ -0,0 +1,139 @@
defmodule Pinchflat.Podcasts.RssFeedBuilder do
@moduledoc """
Methods for building an RSS feed for a source and its media items.
"""
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes
@doc """
Builds an RSS feed for a given source and its media items.
Only MediaItems that have been persisted will be included in the feed.
## Options:
- `:limit` - The maximum number of media items to include in the feed. Defaults to 300.
Returns an XML document as a string.
"""
def build(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 300)
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
build_source_xml(source, media_items, url_base)
end
defp build_source_xml(source, media_items, url_base) do
media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1, url_base))
# "caching" the image path since it requires some DB calls and is used twice
feed_image_path = feed_image_path(url_base, source, media_items)
# Useful: resources:
# - https://validator.w3.org/feed/#validate_by_input
# - https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md
# - https://podba.se/validate
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>#{safe(source.custom_name)}</title>
<link>#{safe(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="#{safe(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>#{safe(feed_image_path)}</url>
<title>#{safe(source.custom_name)}</title>
<link>#{safe(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="#{safe(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>#{safe(media_item.original_url)}</link>
<description>#{safe(media_item.description)}</description>
<pubDate>#{generate_upload_date(media_item)}</pubDate>
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
<enclosure
url="#{media_stream_path(url_base, media_item)}"
length="#{media_item.media_size_bytes}"
type="#{MIME.from_path(media_item.media_filepath)}"
/>
<itunes:author>#{safe(source.custom_name)}</itunes:author>
<itunes:subtitle>#{safe(media_item.title)}</itunes:subtitle>
<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>
<itunes:explicit>false</itunes:explicit>
</item>
"""
end
defp safe(nil), do: ""
defp safe(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
defp generate_self_link(url_base, source) do
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
end
defp media_stream_path(url_base, media_item) do
extension = Path.extname(media_item.media_filepath)
Path.join(url_base, "#{media_route(:stream, media_item.uuid)}#{extension}")
end
defp feed_image_path(url_base, source, media_items) do
case PodcastHelpers.select_cover_image(source, media_items) do
{:error, _} ->
""
{:ok, filepath} ->
extension = Path.extname(filepath)
Path.join(url_base, "#{podcast_route(:feed_image, source.uuid)}#{extension}")
end
end
defp generate_upload_date(media_item) do
media_item.upload_date
|> DatetimeUtils.date_to_datetime()
|> Calendar.strftime(@datetime_format)
end
defp podcast_route(action, params) do
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
end
defp media_route(action, params) do
Routes.media_item_path(PinchflatWeb.Endpoint, action, params)
end
end
+4
View File
@@ -21,6 +21,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
download_metadata
embed_metadata
download_nfo
sponsorblock_behaviour
sponsorblock_categories
shorts_behaviour
livestream_behaviour
preferred_resolution
@@ -47,6 +49,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :embed_metadata, :boolean, default: false
field :download_nfo, :boolean, default: false
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :remove], default: :disabled
field :sponsorblock_categories, {:array, :string}, default: []
# NOTE: these do NOT speed up indexing - the indexer still has to go
# through the entire collection to determine if a media is a short or
# a livestream.
+63
View File
@@ -5,6 +5,10 @@ defmodule Pinchflat.Release do
"""
@app :pinchflat
require Logger
alias Pinchflat.Filesystem.FilesystemHelpers
def migrate do
load_app()
@@ -18,6 +22,38 @@ defmodule Pinchflat.Release do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
def check_file_permissions do
load_app()
directories =
Enum.uniq([
"/config",
"/downloads",
Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :extras_directory),
Application.get_env(:pinchflat, :metadata_directory)
])
Enum.each(directories, fn dir ->
Logger.info("Checking permissions for #{dir}")
filepath = Path.join([dir, ".keep"])
case FilesystemHelpers.write_p(filepath, "") do
:ok ->
Logger.info("Permissions OK")
{:error, :eacces} ->
Logger.error(permission_denied_screed(dir))
raise "Permission denied"
err ->
Logger.error("Permissions check failed: #{inspect(err)}")
raise "Unknown error"
end
end)
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
@@ -25,4 +61,31 @@ defmodule Pinchflat.Release do
defp load_app do
Application.load(@app)
end
defp permission_denied_screed(dir) do
"""
The directory "#{dir}" is not writeable by the Docker container.
Please ensure that the directory exists and is writeable by the Docker
container. All setups are different, but you may be able to run something
like this on the *host*:
chown nobody -R <host path that maps to #{dir}>
chmod 755 -R <host path that maps to #{dir}>
Swapping in your real host path. Then, you should set the user running
this container by editing your `docker run` command like so:
docker run --user 99:100 <rest of the command>
Or adding `user: '99:100'` to the Pinchflat service of your Docker Compose
file. Again, there are many ways to do this depending on your setup and
this is just one example. See issue #106 in the Pinchflat Github for more.
No matter the case, this _is_ a permissions error and allowing the container
to write to the directory is the only way to fix it. It is not recommended
to run the container as `root` because files created by Pinchflat may not
be accessible to other apps that want to modify them.
"""
end
end
+13
View File
@@ -17,6 +17,7 @@ defmodule Pinchflat.Sources.Source do
collection_id
collection_type
custom_name
description
nfo_filepath
poster_filepath
fanart_filepath
@@ -28,6 +29,7 @@ defmodule Pinchflat.Sources.Source do
last_indexed_at
original_url
download_cutoff_date
retention_period_days
title_filter_regex
media_profile_id
)a
@@ -47,6 +49,7 @@ defmodule Pinchflat.Sources.Source do
@pre_insert_required_fields @initially_required_fields ++
~w(
uuid
custom_name
collection_name
collection_id
@@ -54,7 +57,13 @@ defmodule Pinchflat.Sources.Source do
)a
schema "sources" do
# This is _not_ used as the primary key or internally in the database
# relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :custom_name, :string
field :description, :string
field :collection_name, :string
field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
@@ -64,6 +73,7 @@ defmodule Pinchflat.Sources.Source do
field :last_indexed_at, :utc_datetime
# Only download media items that were published after this date
field :download_cutoff_date, :date
field :retention_period_days, :integer
field :original_url, :string
field :title_filter_regex, :string
@@ -96,8 +106,11 @@ defmodule Pinchflat.Sources.Source do
source
|> cast(attrs, @allowed_fields)
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields)
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
end
@doc false
+4 -2
View File
@@ -9,6 +9,7 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceMetadata
@@ -112,8 +113,9 @@ defmodule Pinchflat.Sources do
delete_files = Keyword.get(opts, :delete_files, false)
Tasks.delete_tasks_for(source)
source
|> Media.list_media_items_for()
MediaQuery.new()
|> MediaQuery.for_source(source)
|> Repo.all()
|> Enum.each(fn media_item ->
Media.delete_media_item(media_item, delete_files: delete_files)
end)
+17
View File
@@ -0,0 +1,17 @@
defmodule Pinchflat.Utils.DatetimeUtils do
@moduledoc """
Utility methods for working with dates and datetimes
"""
@doc """
Converts a Date to a DateTime
Returns %DateTime{}
"""
def date_to_datetime(date) do
date
|> Date.to_gregorian_days()
|> Kernel.*(86_400)
|> DateTime.from_gregorian_seconds()
end
end
@@ -8,4 +8,5 @@ defmodule Pinchflat.YtDlp.BackendCommandRunner do
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback version() :: {:ok, binary()} | {:error, binary()}
end
+28 -1
View File
@@ -31,7 +31,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
output_filepath = Keyword.get(addl_opts, :output_filepath, FSUtils.generate_metadata_tmpfile(:json))
print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts)
cookie_opts = build_cookie_options()
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
@@ -47,6 +48,32 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end
end
@impl BackendCommandRunner
def version do
command = backend_executable()
case System.cmd(command, ["--version"]) do
{output, 0} ->
{:ok, String.trim(output)}
{output, _} ->
{:error, output}
end
end
defp build_cookie_options do
base_dir = Application.get_env(:pinchflat, :extras_directory)
cookie_file = Path.join(base_dir, "cookies.txt")
case File.read(cookie_file) do
{:ok, cookie_data} ->
if String.trim(cookie_data) != "", do: [cookies: cookie_file], else: []
{:error, _} ->
[]
end
end
# We want to satisfy the following behaviours:
#
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
+5 -2
View File
@@ -10,7 +10,8 @@ defmodule Pinchflat.YtDlp.Media do
:original_url,
:livestream,
:short_form_content,
:upload_date
:upload_date,
:duration_seconds
]
defstruct [
@@ -20,7 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
:original_url,
:livestream,
:short_form_content,
:upload_date
:upload_date,
:duration_seconds
]
alias __MODULE__
@@ -86,6 +88,7 @@ defmodule Pinchflat.YtDlp.Media do
description: response["description"],
original_url: response["webpage_url"],
livestream: response["was_live"],
duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response),
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
}
+1 -1
View File
@@ -21,7 +21,7 @@ defmodule PinchflatWeb do
def router do
quote do
use Phoenix.Router, helpers: false
use Phoenix.Router, helpers: true
# Import common connection and controller functions to use in pipelines
import Plug.Conn
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
"""
use Phoenix.Component
use Phoenix.Component, global_prefixes: ~w(x-)
import PinchflatWeb.Gettext
@@ -251,7 +251,7 @@ defmodule PinchflatWeb.CoreComponents do
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password
toggle range radio search select tel text textarea time url week)
checkbox_group toggle range radio search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
@@ -304,6 +304,33 @@ defmodule PinchflatWeb.CoreComponents do
"""
end
def input(%{type: "checkbox_group"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
</.label>
<section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1">
<div :for={{option_name, option_value} <- @options} class="flex items-center">
<input
type="checkbox"
id={"#{@id}-#{option_value}"}
name={"#{@name}[]"}
value={option_value}
checked={option_value in @value}
class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]}
/>
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
<%= option_name %>
</label>
</div>
</section>
<.help :if={@help}><%= @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "toggle"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
@@ -347,8 +374,9 @@ defmodule PinchflatWeb.CoreComponents do
id={@id}
name={@name}
class={[
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition",
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white",
"relative z-20 w-full appearance-none rounded border border-form-strokedark py-3 pl-5 pr-12 outline-none transition",
"focus:border-primary active:border-primary bg-form-input text-black text-white",
"disabled:text-opacity-50 disabled:cursor-not-allowed disabled:border-black",
@inputclass
]}
multiple={@multiple}
@@ -600,6 +628,10 @@ defmodule PinchflatWeb.CoreComponents do
_ ->
true
end)
|> Enum.map(fn
{k, v} when is_list(v) -> {k, Enum.join(v, ", ")}
rest -> rest
end)
assigns = assign(assigns, iterable_attributes: attrs)
@@ -654,10 +686,11 @@ defmodule PinchflatWeb.CoreComponents do
"""
attr :name, :string, required: true
attr :class, :string, default: nil
attr :rest, :global
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
<span class={[@name, @class]} {@rest} />
"""
end
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.CustomComponents.ButtonComponents do
@moduledoc false
use Phoenix.Component
use Phoenix.Component, global_prefixes: ~w(x-)
alias PinchflatWeb.CoreComponents
@doc """
Render a button
@@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
</button>
"""
end
@doc """
Render a dropdown based off a button
## Examples
<.button_dropdown text="Actions">
<:option>TEST</:option>
</.button_dropdown>
"""
attr :text, :string, required: true
attr :class, :string, default: ""
slot :option, required: true
def button_dropdown(assigns) do
~H"""
<div x-data="{ dropdownOpen: false }" class={["relative flex", @class]}>
<span
x-on:click.prevent="dropdownOpen = !dropdownOpen"
class={[
"cursor-pointer inline-flex gap-2.5 rounded-md bg-primary px-5.5 py-3",
"font-medium text-white hover:bg-opacity-95"
]}
>
<%= @text %>
<CoreComponents.icon
name="hero-chevron-down"
class="fill-current duration-200 ease-linear mt-1"
x-bind:class="dropdownOpen && 'rotate-180'"
/>
</span>
<div
x-show="dropdownOpen"
x-on:click.outside="dropdownOpen = false"
class="absolute left-0 top-full z-40 mt-2 w-full rounded-md bg-black py-3 shadow-card"
>
<ul class="flex flex-col">
<li :for={option <- @option}>
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
<%= render_slot(option) %>
</span>
</li>
</ul>
</div>
</div>
"""
end
end
@@ -9,24 +9,32 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
attr :title, :string, required: true
end
slot :tab_append, required: false
def tabbed_layout(assigns) do
~H"""
<div
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
x-data="{ openTab: getTabIndex(0), activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
@hashchange.window="openTab = getTabIndex(openTab)"
class="w-full"
>
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10">
<a
:for={{tab, idx} <- Enum.with_index(@tab)}
href="#"
@click.prevent={"openTab = #{idx}"}
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
class="border-b-2 py-4 text-sm font-medium hover:text-meta-5 md:text-base"
>
<span class="text-xl"><%= tab.title %></span>
</a>
</div>
<div>
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
<div class="flex flex-wrap gap-5 sm:gap-10">
<a
:for={{tab, idx} <- Enum.with_index(@tab)}
href="#"
@click.prevent={"openTab = setTabIndex(#{idx})"}
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
>
<span class="text-xl"><%= tab.title %></span>
</a>
</div>
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
<%= render_slot(@tab_append) %>
</div>
</header>
<div class="mt-4">
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
<%= render_slot(tab) %>
</div>
@@ -57,6 +57,14 @@
<.icon name="hero-currency-dollar" /> Donate
</span>
</li>
<li>
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %>
</span>
<span class="group relative flex items-center gap-2.5 px-4 text-sm">
yt-dlp <%= Settings.get!(:yt_dlp_version) %>
</span>
</li>
</ul>
</nav>
</section>
@@ -0,0 +1,9 @@
defmodule PinchflatWeb.HealthController do
use PinchflatWeb, :controller
def check(conn, _params) do
conn
|> put_status(:ok)
|> json(%{status: "ok"})
end
end
@@ -1,8 +1,11 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
def show(conn, %{"id" => id}) do
media_item =
@@ -13,20 +16,99 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
render(conn, :show, media_item: media_item)
end
def delete(conn, %{"id" => id} = params) do
delete_files = Map.get(params, "delete_files", false)
def edit(conn, %{"id" => id}) do
media_item = Media.get_media_item!(id)
{:ok, _} = Media.delete_media_item(media_item, delete_files: delete_files)
changeset = Media.change_media_item(media_item)
flash_message =
if delete_files do
"Record and files deleted successfully."
else
"Record deleted successfully. Files were not deleted."
end
render(conn, :edit, media_item: media_item, changeset: changeset)
end
def update(conn, %{"id" => id, "media_item" => params}) do
media_item = Media.get_media_item!(id)
case Media.update_media_item(media_item, params) do
{:ok, media_item} ->
conn
|> put_flash(:info, "Media Item updated successfully.")
|> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit, media_item: media_item, changeset: changeset)
end
end
def delete(conn, %{"id" => id} = params) do
prevent_download = Map.get(params, "prevent_download", false)
media_item = Media.get_media_item!(id)
{:ok, _} = Media.delete_media_files(media_item, %{prevent_download: prevent_download})
conn
|> put_flash(:info, flash_message)
|> put_flash(:info, "Files deleted successfully.")
|> redirect(to: ~p"/sources/#{media_item.source_id}")
end
# See here for details on streaming files and range requests:
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
#
# Uses the UUID instead of the ID to avoid enumeration attacks
# since streaming is a public endpoint (ie: no auth required)
def stream(conn, %{"uuid" => uuid}) do
media_item = Repo.get_by!(MediaItem, uuid: uuid)
if File.exists?(media_item.media_filepath) do
file_size = File.stat!(media_item.media_filepath).size
mime_type = MIME.from_path(media_item.media_filepath)
case parse_range(conn, file_size) do
{:ok, {start_pos, end_pos}} ->
Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{end_pos}")
length = end_pos - start_pos + 1
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|> put_resp_header("content-length", to_string(length))
|> send_file(206, media_item.media_filepath, start_pos, length)
{:error, :invalid_range} ->
Logger.debug("Invalid range request for media item: #{media_item.uuid} - serving full file")
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("content-length", to_string(file_size))
|> put_resp_header("accept-ranges", "bytes")
|> send_file(200, media_item.media_filepath)
end
else
send_resp(conn, 404, "File not found")
end
end
defp parse_range(conn, file_size) do
with [range_header | _] <- get_req_header(conn, "range"),
["bytes", range] <- String.split(range_header, "="),
[start_pos, end_pos] <- String.split(range, "-") do
validate_range(start_pos, end_pos, file_size)
else
_ -> {:error, :invalid_range}
end
end
defp validate_range(start_pos, end_pos, file_size) do
case {Integer.parse(start_pos), Integer.parse(end_pos)} do
{:error, :error} ->
{:error, :invalid_range}
{{start_pos, _}, :error} ->
{:ok, {start_pos, file_size - 1}}
# See RFC7233
{{start_pos, _}, {end_pos, _}} when end_pos >= file_size ->
{:ok, {start_pos, file_size - 1}}
{{start_pos, _}, {end_pos, _}} ->
{:ok, {start_pos, end_pos}}
end
end
end
@@ -2,4 +2,24 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
use PinchflatWeb, :html
embed_templates "media_item_html/*"
@doc """
Renders a media item form.
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
def media_item_form(assigns)
def media_file_exists?(media_item) do
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
end
def media_type(media_item) do
case Path.extname(media_item.media_filepath) do
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
ext when ext in [".mp3", ".m4a"] -> :audio
_ -> :unknown
end
end
end
@@ -0,0 +1,13 @@
<div class="mb-6 flex gap-3 flex-row items-center">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= StringUtils.truncate(@media_item.title, 35) %>"
</h2>
</div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10">
<.media_item_form changeset={@changeset} action={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"} />
</div>
</div>
</div>
@@ -0,0 +1,31 @@
<.simple_form
:let={f}
for={@changeset}
action={@action}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<h3 class=" text-2xl text-black dark:text-white">
General Options
</h3>
<.input
field={f[:prevent_download]}
type="toggle"
label="Prevent Download"
help="Checking excludes this media item from being downloaded"
/>
<.input
field={f[:prevent_culling]}
type="toggle"
label="Prevent Automatic Deletion"
help="Checking excludes media from being automatically deleted based on media retention rules"
/>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media Item</.button>
</.simple_form>
@@ -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 %>
@@ -4,15 +4,52 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Media Item #<%= @media_item.id %>
<%= StringUtils.truncate(@media_item.title, 35) %>
</h2>
</div>
<nav>
<.link href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}/edit"}>
<.button color="bg-primary" rounding="rounded-lg">
<.icon name="hero-pencil-square" class="mr-2" /> Edit
</.button>
</.link>
</nav>
</div>
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
<div class="max-w-full overflow-x-auto">
<.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
method="delete"
data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
>
Delete Files
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
method="delete"
data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
>
Delete and Ignore
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes">
<div class="flex flex-col gap-10 dark:text-white">
<%= if media_file_exists?(@media_item) do %>
<h3 class="font-bold text-xl">Preview</h3>
<.media_preview media_item={@media_item} />
<% end %>
<h2 class="font-bold text-2xl"><%= @media_item.title %></h2>
<h3 class="font-bold text-xl">Attributes</h3>
<section>
<strong>Source:</strong>
@@ -23,18 +60,6 @@
<.list_items_from_map map={Map.from_struct(@media_item)} />
</div>
<section class="flex justify-center my-10">
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Tasks">
<%= if match?([_|_], @media_item.tasks) do %>
@@ -32,6 +32,26 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
]
end
def friendly_sponsorblock_options do
[
{"Disabled (default)", "disabled"},
{"Remove Segments", "remove"}
]
end
def frieldly_sponsorblock_categories do
[
{"Sponsor", "sponsor"},
{"Intro/Intermission", "intro"},
{"Outro/Credits", "outro"},
{"Self Promotion", "selfpromo"},
{"Preview/Recap", "preview"},
{"Filler Tangent", "filler"},
{"Interaction Reminder", "interaction"},
{"Non-music Section", "music_offtopic"}
]
end
def custom_output_template_options do
%{
upload_day: nil,
@@ -39,7 +59,8 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
upload_year: nil,
upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD",
source_custom_name: "the name of the sources that use this profile",
source_collection_type: "the collection type of the sources that use this profile. Either 'channel' or 'playlist'"
source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'",
artist_name: "the name of the artist with fallbacks to other uploader fields"
}
end
@@ -74,6 +95,6 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
end
defp audio_output_template do
"/music/{{ source_custom_name }}/{{ title }}.{{ ext }}"
"/music/{{ artist_name }}/{{ title }}.{{ ext }}"
end
end
@@ -38,7 +38,7 @@
presets: {
default: 'Default',
media_center: 'TV Shows',
audio: 'Audio',
audio: 'Music',
archiving: 'Archiving'
}
}">
@@ -238,6 +238,36 @@
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
SponsorBlock Options
</h3>
<section x-data="{ sponsorblockBehaviour: null }">
<section x-data="{ presets: { default: 'disabled', media_center: 'disabled', audio: 'disabled', archiving: 'disabled' } }">
<.input
field={f[:sponsorblock_behaviour]}
options={friendly_sponsorblock_options()}
type="select"
label="SponsorBlock Behaviour"
help="Action to take when SponsorBlock segments are found. 'Disabled' won't take any action"
x-model="sponsorblockBehaviour"
x-init="
sponsorblockBehaviour = $el.value
$watch('selectedPreset', p => p && ($el.value = presets[p]))
"
/>
</section>
<section x-show="sponsorblockBehaviour !== 'disabled'" x-transition>
<.input
field={f[:sponsorblock_categories]}
options={frieldly_sponsorblock_categories()}
type="checkbox_group"
label="SponsorBlock Categories"
/>
</section>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
</section>
@@ -19,38 +19,42 @@
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full overflow-x-auto">
<.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
href={~p"/media_profiles/#{@media_profile}"}
method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
>
Delete Profile
</.link>
</:option>
<:option>
<.link
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Profile + Files
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes">
<div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
<.list_items_from_map map={Map.from_struct(@media_profile)} />
</div>
<section class="flex flex-col md:flex-row items-center md:justify-around my-10">
<.link
href={~p"/media_profiles/#{@media_profile}"}
method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-lg">
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-lg">
Delete Profile, Sources, and Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name">
<%= source.custom_name || source.collection_name %>
<.subtle_link href={~p"/sources/#{source.id}"}>
<%= source.custom_name || source.collection_name %>
</.subtle_link>
</:col>
<:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Should Download?">
@@ -1,27 +1,27 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<div class="mt-4 flex flex-col items-center justify-center">
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center">
<span class="text-md font-medium">Media Profile(s)</span>
<h4 class="text-title-md font-bold text-white">
<%= @media_profile_count %>
</h4>
</div>
</a>
</div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<div class="mt-4 flex flex-col items-center justify-center">
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center">
<span class="text-md font-medium">Source(s)</span>
<h4 class="text-title-md font-bold text-white">
<%= @source_count %>
</h4>
</div>
</a>
</div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<div class="mt-4 flex flex-col items-center justify-center">
<a href="#" class="mt-4 flex flex-col items-center justify-center">
<span class="text-md font-medium">Media Item(s)</span>
<h4 class="text-title-md font-bold text-white">
<%= @media_item_count %>
</h4>
</div>
</a>
</div>
<span class="text-strokedark">I know this page isn't super useful yet, but give it time :&rpar;</span>
</div>
@@ -0,0 +1,38 @@
defmodule PinchflatWeb.Podcasts.PodcastController do
use PinchflatWeb, :controller
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Sources.Source
alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.PodcastHelpers
def rss_feed(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid)
url_base = url(conn, ~p"/")
xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base)
conn
|> put_resp_content_type("application/rss+xml")
|> put_resp_header("content-disposition", "inline")
|> send_resp(200, xml)
end
def feed_image(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid)
# This provides a fallback image if the source has none.
# We only need one since we're using the internal metadata image which
# we know exists.
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
case PodcastHelpers.select_cover_image(source, media_items) do
{:error, _} ->
send_resp(conn, 404, "Image not found")
{:ok, filepath} ->
conn
|> put_resp_content_type(MIME.from_path(filepath))
|> send_file(200, filepath)
end
end
end
@@ -1,12 +1,15 @@
defmodule PinchflatWeb.Sources.SourceController do
use PinchflatWeb, :controller
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Profiles
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
def index(conn, _params) do
sources = Repo.preload(Sources.list_sources(), :media_profile)
@@ -54,7 +57,8 @@ defmodule PinchflatWeb.Sources.SourceController do
source: source,
pending_tasks: pending_tasks,
pending_media: pending_media,
downloaded_media: downloaded_media
downloaded_media: downloaded_media,
total_downloaded: total_downloaded_for(source)
)
end
@@ -104,6 +108,15 @@ defmodule PinchflatWeb.Sources.SourceController do
Profiles.list_media_profiles()
end
# NOTE: should move this out of the controller
# once I finally add some query fragment layer
defp total_downloaded_for(source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.aggregate(:count, :id)
end
defp get_onboarding_layout do
if Settings.get!(:onboarding) do
{Layouts, :onboarding}
@@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do
{"Monthly", 30 * 24 * 60}
]
end
def rss_feed_url(conn, source) do
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end
end
@@ -22,6 +22,13 @@
<:col :let={source} label="Should Download?">
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
</:col>
<:col :let={source} label="Retention">
<%= if source.retention_period_days && source.retention_period_days > 0 do %>
<%= source.retention_period_days %> day(s)
<% else %>
<span class="text-lg">∞</span>
<% end %>
</:col>
<:col :let={source} label="Media Profile">
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
<%= source.media_profile.name %>
@@ -19,6 +19,46 @@
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto">
<.tabbed_layout>
<:tab_append>
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
window.copyTextToClipboard('#{rss_feed_url(@conn, @source)}')
copied = true
setTimeout(() => copied = false, 4000)
"}
>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</:option>
<:option>
<div class="h-px w-full bg-bodydark2"></div>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
Delete Source
</.link>
</:option>
<:option>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
Delete Source + Files
</.link>
</:option>
</.button_dropdown>
</:tab_append>
<:tab title="Attributes">
<div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-lg">Attributes</h3>
@@ -31,38 +71,23 @@
<.list_items_from_map map={Map.from_struct(@source)} />
</div>
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Source
</.button>
</.link>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Source and Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Pending Media">
<%= if match?([_|_], @pending_media) do %>
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
<.table rows={@pending_media} table_class="text-black dark:text-white">
<:col :let={media_item} label="Title">
<%= StringUtils.truncate(media_item.title, 50) %>
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= StringUtils.truncate(media_item.title, 50) %>
</.subtle_link>
</:col>
<:col :let={media_item} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
<.icon_link
href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"}
icon="hero-pencil-square"
class="mx-1"
/>
</:col>
</.table>
<% else %>
@@ -71,13 +96,20 @@
</:tab>
<:tab title="Downloaded Media">
<%= if match?([_|_], @downloaded_media) do %>
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
<h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items (<%= @total_downloaded %> total)</h4>
<.table rows={@downloaded_media} table_class="text-black dark:text-white">
<:col :let={media_item} label="Title">
<%= StringUtils.truncate(media_item.title, 50) %>
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= StringUtils.truncate(media_item.title, 50) %>
</.subtle_link>
</:col>
<:col :let={media_item} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
<.icon_link
href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"}
icon="hero-pencil-square"
class="mx-1"
/>
</:col>
</.table>
<% else %>
@@ -39,24 +39,32 @@
Indexing Options
</h3>
<.input
field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()}
type="select"
label="Index Frequency"
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
/>
<%!-- TODO: use Alpine to disable the index frequency when fast indexing is enabled --%>
<div phx-click={show_modal("upgrade-modal")}>
<section x-data="{ fastIndexingEnabled: null }">
<.input
field={f[:fast_index]}
type="toggle"
label="Use Fast Indexing"
label_suffix="(pro)"
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()}
type="select"
label="Index Frequency"
x-bind:disabled="fastIndexingEnabled == true"
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
/>
</div>
<div phx-click={show_modal("upgrade-modal")}>
<.input
field={f[:fast_index]}
type="toggle"
label="Use Fast Indexing"
label_suffix="(pro)"
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
x-init="
// `enabled` is the data attribute that the toggle uses internally
fastIndexingEnabled = enabled
$watch('enabled', value => fastIndexingEnabled = !!value)
"
/>
</div>
</section>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Downloading Options
@@ -80,6 +88,14 @@
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
/>
<.input
field={f[:retention_period_days]}
type="number"
label="Retention Period (days)"
min="0"
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely"
/>
<section x-show="advancedMode">
<h3 class="mt-8 text-2xl text-black dark:text-white">
Advanced Options
+49
View File
@@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug :override_base_url
plug :strip_trailing_extension
plug PinchflatWeb.Router
# URLs need to be generated using the host of the current page being accessed
# for things like Podcast RSS feeds to contain links to the right location.
#
# Normally you'd set the `url` option in the Endpoint configuration, but
# since this is self-hosted and often accessed at multiple different URLs,
# that would probably be more difficult for end-users to set up than just
# having the application figure it out.
defp override_base_url(conn, _opts) do
new_scheme =
case get_req_header(conn, "x-forwarded-proto") do
[scheme] -> scheme
_ -> to_string(conn.scheme)
end
new_port = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
new_base_url = "#{new_scheme}://#{conn.host}#{new_port}"
Phoenix.Controller.put_router_url(conn, new_base_url)
end
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
defp strip_trailing_extension(conn, _opts) do
path =
conn.path_info
|> List.last()
|> String.split(".")
|> Enum.reverse()
case path do
[_] ->
conn
[_format | fragments] ->
new_path =
fragments
|> Enum.reverse()
|> Enum.join(".")
path_fragments = List.replace_at(conn.path_info, -1, new_path)
%{conn | path_info: path_fragments}
end
end
end
+41 -17
View File
@@ -1,6 +1,9 @@
defmodule PinchflatWeb.Router do
use PinchflatWeb, :router
import Phoenix.LiveDashboard.Router
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
# the extension from the path
pipeline :browser do
plug :basic_auth
plug :accepts, ["html"]
@@ -9,12 +12,17 @@ defmodule PinchflatWeb.Router do
plug :put_root_layout, html: {PinchflatWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :allow_iframe_embed
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :feeds do
plug :maybe_basic_auth
end
scope "/", PinchflatWeb do
pipe_through :browser
@@ -24,29 +32,41 @@ defmodule PinchflatWeb.Router do
resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/sources", Sources.SourceController do
resources "/media", MediaItems.MediaItemController, only: [:show, :delete]
resources "/media", MediaItems.MediaItemController, only: [:show, :edit, :update, :delete]
end
end
# Other scopes may use custom stacks.
# scope "/api", PinchflatWeb do
# pipe_through :api
# end
# Routes in here _may not be_ protected by basic auth. This is necessary for
# media streaming to work for RSS podcast feeds.
scope "/", PinchflatWeb do
pipe_through :feeds
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:pinchflat, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
scope "/dev" do
pipe_through :browser
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
end
live_dashboard "/dashboard", metrics: PinchflatWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
# No auth or CSRF protection for the health check endpoint
scope "/", PinchflatWeb do
pipe_through :api
get "/healthcheck", HealthController, :check
end
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard",
metrics: PinchflatWeb.Telemetry,
ecto_repos: [Pinchflat.Repo]
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
@@ -64,4 +84,8 @@ defmodule PinchflatWeb.Router do
defp credential_set?(credential) do
credential && credential != ""
end
defp allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end
end
+3 -2
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "0.1.0-alpha.8",
version: "0.1.7",
elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@@ -33,7 +33,7 @@ defmodule Pinchflat.MixProject do
def application do
[
mod: {Pinchflat.Application, []},
extra_applications: [:logger, :runtime_tools, :inets]
extra_applications: [:logger, :runtime_tools, :inets, :os_mon]
]
end
@@ -50,6 +50,7 @@ defmodule Pinchflat.MixProject do
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.10"},
{:ecto_sqlite3, ">= 0.0.0"},
{:ecto_sqlite3_extras, "~> 1.2.0"},
{:phoenix_html, "~> 3.3"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.20.1"},
+2
View File
@@ -13,6 +13,7 @@
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
"elixir_make": {:hex, :elixir_make, "0.7.8", "505026f266552ee5aabca0b9f9c229cbb496c689537c9f922f3eb5431157efc7", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "7a71945b913d37ea89b06966e1342c85cfe549b15e6d6d081e8081c493062c07"},
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
@@ -46,6 +47,7 @@
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"},
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"},
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
@@ -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
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.AddSponsorblockToMediaProfiles do
use Ecto.Migration
def change do
alter table(:media_profiles) do
add :sponsorblock_behaviour, :string, default: "disabled"
add :sponsorblock_categories, {:array, :string}, default: []
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddDurationToMediaItems do
use Ecto.Migration
def change do
alter table(:media_items) do
add :duration_seconds, :integer
end
end
end
@@ -0,0 +1,19 @@
defmodule Pinchflat.Repo.Migrations.ReReAddSourceUniquenessIndex do
use Ecto.Migration
def up do
execute """
CREATE UNIQUE INDEX sources_collection_id_media_profile_id_title_filter_regex_index ON sources (
collection_id,
media_profile_id,
IFNULL(title_filter_regex, '')
);
"""
end
def down do
execute """
DROP INDEX sources_collection_id_media_profile_id_title_filter_regex_index;
"""
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddPreventDownloadToMediaItems do
use Ecto.Migration
def change do
alter table(:media_items) do
add :prevent_download, :boolean, default: false, null: false
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddRetentionPeriodToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :retention_period_days, :integer
end
end
end
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.AddCulledAtToMediaItems do
use Ecto.Migration
def change do
alter table(:media_items) do
add :culled_at, :utc_datetime
add :prevent_culling, :boolean, default: false
end
end
end
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
cd -P -- "$(dirname -- "$0")"
exec ./pinchflat eval Pinchflat.Release.check_file_permissions
+7
View File
@@ -1,4 +1,11 @@
#!/bin/sh
/app/bin/check_file_permissions
if [ $? -ne 0 ]; then
echo "Filesystem error. Exiting."
exit 1
fi
/app/bin/migrate
cd -P -- "$(dirname -- "$0")"
+10 -19
View File
@@ -39,6 +39,7 @@ RUN mix local.hex --force && \
# set build ENV
ENV MIX_ENV="prod"
ENV ERL_FLAGS="+JPperf true"
# install mix dependencies
COPY mix.exs mix.lock ./
@@ -74,9 +75,11 @@ RUN mix release
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
ARG PORT=8945
RUN apt-get update -y
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
ffmpeg curl git openssh-client
ffmpeg curl git openssh-client nano
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
# Download and update YT-DLP
@@ -92,38 +95,26 @@ ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# Set up data volumes
RUN mkdir /config /downloads
RUN chown nobody /config /downloads
# set runner ENV
ENV MIX_ENV="prod"
ENV CONFIG_PATH="/config"
ENV PORT=8945
ENV PORT=${PORT}
ENV RUN_CONTEXT="selfhosted"
EXPOSE ${PORT}
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pinchflat ./
# NEVER do this if you're running in an environment where you don't trust the user
# (ie: most environments). This is only acceptable in a self-hosted environment.
# The user could just run the whole container as root and bypass this anyway so
# it's not a huge deal.
# This removes the root password to allow users to assume root if needed. This is
# preferrable to running the whole container as root so that the files/directories
# created by the app aren't owned by root and are therefore easier for other users
# and processes to interact with. If you want to just run the whole container as
# root, use --user 0:0 or something.
RUN passwd -d root
USER nobody
COPY --from=builder /app/_build/${MIX_ENV}/rel/pinchflat ./
# If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"]
HEALTHCHECK --interval=120s --start-period=10s \
CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
# Start the app
CMD ["/app/bin/docker_start"]
@@ -1,70 +0,0 @@
defmodule Pinchflat.Boot.DataBackfillWorkerTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
alias Pinchflat.Boot.DataBackfillWorker
alias Pinchflat.JobFixtures.TestJobWorker
describe "cancel_pending_backfill_jobs/0" do
test "cancels all pending backfill jobs" do
%{}
|> DataBackfillWorker.new()
|> Repo.insert_unique_job()
assert_enqueued(worker: DataBackfillWorker)
DataBackfillWorker.cancel_pending_backfill_jobs()
refute_enqueued(worker: DataBackfillWorker)
end
test "does not cancel jobs for other workers" do
%{id: 0}
|> TestJobWorker.new()
|> Repo.insert_unique_job()
assert_enqueued(worker: TestJobWorker)
DataBackfillWorker.cancel_pending_backfill_jobs()
assert_enqueued(worker: TestJobWorker)
end
end
describe "perform/1" do
setup do
DataBackfillWorker.cancel_pending_backfill_jobs()
:ok
end
test "reschedules itself once complete" do
perform_job(DataBackfillWorker, %{})
assert_enqueued(worker: DataBackfillWorker, scheduled_at: now_plus(60, :minutes))
end
end
describe "perform/1 when testing backfill_shorts_data" do
test "sets short_form_content to true for media items with shorts in the URL" do
media_item = media_item_with_attachments(%{original_url: "https://example.com/shorts/123"})
refute media_item.short_form_content
perform_job(DataBackfillWorker, %{})
assert Repo.reload!(media_item).short_form_content
end
test "does not set short_form_content to true for media items without shorts in the URL" do
media_item = media_item_with_attachments(%{original_url: "https://example.com/longs/123"})
refute media_item.short_form_content
perform_job(DataBackfillWorker, %{})
refute Repo.reload!(media_item).short_form_content
end
end
end
@@ -1,8 +1,8 @@
defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.ProfilesFixtures
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Profiles
alias Pinchflat.Downloading.DownloadOptionBuilder
@@ -222,6 +222,45 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end
end
describe "build/1 when testing sponsorblock options" do
test "includes :sponsorblock_remove option when specified", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :remove,
sponsorblock_categories: ["sponsor", "intro"]
})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:sponsorblock_remove, "sponsor,intro"} in res
end
test "does not include :sponsorblock_remove option without categories", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :remove,
sponsorblock_categories: []
})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res
refute {:sponsorblock_remove, []} in res
refute :sponsorblock_remove in res
end
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{sponsorblock_behaviour: :disabled})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res
refute {:sponsorblock_remove, []} in res
refute :sponsorblock_remove in res
end
end
describe "build_output_path_for/1" do
test "builds an output path for a source", %{media_item: media_item} do
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
@@ -74,6 +74,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end)
end
test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error", 1} end)
assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id})
end
test "it does not download if the source is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> :ok end)
@@ -35,7 +35,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "it saves the metadata filepatha to the database", %{media_item: media_item} do
test "it saves the metadata filepath to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
@@ -58,7 +58,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing media_item attributes" do
setup do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
@@ -93,16 +93,51 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
end
test "it extracts the duration_seconds", %{media_item: media_item} do
assert media_item.duration_seconds == nil
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert is_integer(updated_media_item.duration_seconds)
end
test "it extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
metadata = render_parsed_metadata(:media_metadata)
thumbnail_filepath =
metadata["thumbnails"]
|> Enum.reverse()
|> Enum.find_value(fn attrs -> attrs["filepath"] end)
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
:ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath)
{:ok, Phoenix.json_library().encode!(metadata)}
end)
assert media_item.thumbnail_filepath == nil
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert String.ends_with?(updated_media_item.thumbnail_filepath, ".webp")
File.rm(updated_media_item.thumbnail_filepath)
end
test "it extracts the metadata_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
metadata = render_parsed_metadata(:media_metadata)
infojson_filepath = metadata["infojson_filename"]
:ok = File.cp(infojson_filepath_fixture(), infojson_filepath)
{:ok, Phoenix.json_library().encode!(metadata)}
end)
assert media_item.metadata_filepath == nil
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert String.ends_with?(updated_media_item.metadata_filepath, ".info.json")
File.rm(updated_media_item.metadata_filepath)
end
end
@@ -0,0 +1,70 @@
defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Media
alias Pinchflat.Downloading.MediaRetentionWorker
describe "perform/1" do
test "deletes media files that are past their retention date" do
{_source, old_media_item, new_media_item} = prepare_records()
perform_job(MediaRetentionWorker, %{})
assert File.exists?(new_media_item.media_filepath)
refute File.exists?(old_media_item.media_filepath)
assert Repo.reload!(new_media_item).media_filepath
refute Repo.reload!(old_media_item).media_filepath
end
test "sets deleted media to not re-download" do
{_source, old_media_item, new_media_item} = prepare_records()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).prevent_download
assert Repo.reload!(old_media_item).prevent_download
end
test "sets culled_at timestamp on deleted media" do
{_source, old_media_item, new_media_item} = prepare_records()
perform_job(MediaRetentionWorker, %{})
refute Repo.reload!(new_media_item).culled_at
assert Repo.reload!(old_media_item).culled_at
assert DateTime.diff(now(), Repo.reload!(old_media_item).culled_at) < 1
end
test "doesn't cull media items that have prevent_culling set" do
{_source, old_media_item, _new_media_item} = prepare_records()
Media.update_media_item(old_media_item, %{prevent_culling: true})
perform_job(MediaRetentionWorker, %{})
assert File.exists?(old_media_item.media_filepath)
assert Repo.reload!(old_media_item).media_filepath
end
end
defp prepare_records do
source = source_fixture(%{retention_period_days: 2})
old_media_item =
media_item_with_attachments(%{
source_id: source.id,
media_downloaded_at: now_minus(3, :days)
})
new_media_item =
media_item_with_attachments(%{
source_id: source.id,
media_downloaded_at: now_minus(1, :day)
})
{source, old_media_item, new_media_item}
end
end
@@ -34,6 +34,29 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
end
end
describe "write_p/3" do
test "writes content to a file" do
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
content = "{}"
assert :ok = FilesystemHelpers.write_p(filepath, content)
assert File.read!(filepath) == content
File.rm!(filepath)
end
test "creates directories as needed" do
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
filepath = Path.join([tmpfile_directory, "foo", "bar", "file.json"])
content = "{}"
assert :ok = FilesystemHelpers.write_p(filepath, content)
assert File.read!(filepath) == content
File.rm!(filepath)
end
end
describe "write_p!/3" do
test "writes content to a file" do
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
+214 -17
View File
@@ -38,29 +38,95 @@ defmodule Pinchflat.MediaTest do
end
end
describe "list_media_items_for/1" do
test "it returns media_items for a given source" do
source = source_fixture()
media_item = media_item_fixture(%{source_id: source.id})
describe "list_cullable_media_items/0" do
test "returns media items where the source has a retention period" do
source_one = source_fixture(%{retention_period_days: 2})
source_two = source_fixture(%{retention_period_days: 0})
source_three = source_fixture(%{retention_period_days: nil})
assert Media.list_media_items_for(source) == [media_item]
end
end
_media_item =
media_item_fixture(%{
source_id: source_two.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
describe "list_media_items_by_media_id_for/2" do
test "returns media_items for a given source and media_ids" do
source = source_fixture()
media_item = media_item_fixture(%{source_id: source.id, media_id: "123"})
_media_item =
media_item_fixture(%{
source_id: source_three.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_media_items_by_media_id_for(source, ["123"]) == [media_item]
expected_media_item =
media_item_fixture(%{
source_id: source_one.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
test "does not return matching media_ids for a different source" do
source = source_fixture()
other_source = source_fixture()
_media_item = media_item_fixture(%{source_id: other_source.id, media_id: "123"})
test "returns media_items with a media_filepath" do
source = source_fixture(%{retention_period_days: 2})
assert Media.list_media_items_by_media_id_for(source, ["123"]) == []
_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: nil,
media_downloaded_at: now_minus(3, :days)
})
expected_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
test "returns items that have passed their retention period" do
source = source_fixture(%{retention_period_days: 2})
_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(2, :days)
})
expected_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
test "doesn't return items that are set to prevent culling" do
source = source_fixture(%{retention_period_days: 2})
_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days),
prevent_culling: true
})
expected_media_item =
media_item_fixture(%{
source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}",
media_downloaded_at: now_minus(3, :days)
})
assert Media.list_cullable_media_items() == [expected_media_item]
end
end
@@ -259,6 +325,16 @@ defmodule Pinchflat.MediaTest do
end
end
describe "list_pending_media_items_for/1 when testing download prevention" do
test "returns only media items that are not prevented from downloading" do
source = source_fixture()
_prevented_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: true})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, prevent_download: false})
assert Media.list_pending_media_items_for(source) == [media_item]
end
end
describe "list_downloaded_media_items_for/1" do
test "returns only media items with a media_filepath" do
source = source_fixture()
@@ -325,6 +401,39 @@ defmodule Pinchflat.MediaTest do
assert Media.pending_download?(media_item)
end
test "returns true if the content matches the title regex" do
source = source_fixture(%{title_filter_regex: "(?i)^FOO$"})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
assert Media.pending_download?(media_item)
end
test "returns false if the content doesn't match the title regex" do
source = source_fixture(%{title_filter_regex: "(?i)^FOO$"})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "bar"})
refute Media.pending_download?(media_item)
end
test "return true if there is no title regex" do
source = source_fixture(%{title_filter_regex: nil})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
assert Media.pending_download?(media_item)
end
test "returns true if the media item is not prevented from downloading" do
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: false})
assert Media.pending_download?(media_item)
end
test "returns false if the media item is prevented from downloading" do
media_item = media_item_fixture(%{media_filepath: nil, prevent_download: true})
refute Media.pending_download?(media_item)
end
end
describe "search/1" do
@@ -398,6 +507,37 @@ defmodule Pinchflat.MediaTest do
assert media_item.media_filepath == valid_attrs.media_filepath
end
test "automatically sets the UUID" do
valid_attrs = %{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today()
}
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
assert String.length(media_item.uuid) == 36
end
test "UUID is not writable by the user" do
valid_attrs = %{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today(),
uuid: "some-uuid"
}
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
assert String.length(media_item.uuid) == 36
end
test "creating with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Media.create_media_item(@invalid_attrs)
end
@@ -561,6 +701,63 @@ defmodule Pinchflat.MediaTest do
end
end
describe "delete_media_files/2" do
test "does not delete the media_item" do
media_item = media_item_fixture()
assert {:ok, %MediaItem{}} = Media.delete_media_files(media_item)
assert Repo.reload!(media_item)
end
test "deletes attached tasks" do
media_item = media_item_fixture()
task = task_fixture(%{media_item_id: media_item.id})
assert {:ok, %MediaItem{}} = Media.delete_media_files(media_item)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "deletes the media_item's files" do
media_item = media_item_with_attachments()
assert File.exists?(media_item.media_filepath)
assert {:ok, _} = Media.delete_media_files(media_item)
refute File.exists?(media_item.media_filepath)
end
test "does not delete the media item's metadata files" do
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), :metadata)
update_attrs = %{
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
thumbnail_filepath:
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, %{
"thumbnail" => "https://example.com/thumbnail.jpg"
})
}
}
{:ok, updated_media_item} = Media.update_media_item(media_item, update_attrs)
metadata = Repo.preload(updated_media_item, :metadata).metadata
assert {:ok, _} = Media.delete_media_files(updated_media_item)
assert Repo.reload(metadata)
assert File.exists?(updated_media_item.metadata.metadata_filepath)
# cleanup
Media.delete_media_item(updated_media_item, delete_files: true)
end
test "can take additional attributes update media item" do
media_item = media_item_with_attachments()
assert {:ok, updated_media_item} = Media.delete_media_files(media_item, %{prevent_download: true})
assert updated_media_item.prevent_download
end
end
describe "change_media_item/1" do
test "change_media_item/1 returns a media_item changeset" do
media_item = media_item_fixture()
@@ -140,4 +140,14 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end
end
end
describe "metadata_directory_for/1" do
test "returns the metadata directory for the given record", %{media_item: media_item} do
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
metadata_directory = Helpers.metadata_directory_for(media_item)
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
end
end
end
@@ -1,5 +1,6 @@
defmodule Pinchflat.Metadata.MetadataParserTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
alias Pinchflat.Metadata.MetadataParser, as: Parser
@@ -44,6 +45,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
assert result.livestream == metadata["was_live"]
end
test "it extracts the duration in seconds", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert result.duration_seconds == round(metadata["duration"])
end
end
describe "parse_for_media_item/1 when testing subtitle metadata" do
@@ -88,6 +95,22 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
end
describe "parse_for_media_item/1 when testing thumbnail metadata" do
setup %{metadata: metadata} do
thumbnail_filepath =
metadata["thumbnails"]
|> Enum.reverse()
|> Enum.find_value(fn attrs -> attrs["filepath"] end)
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
:ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath)
on_exit(fn -> File.rm(thumbnail_filepath) end)
{:ok, filepath: thumbnail_filepath}
end
test "extracts the thumbnail filepath", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
@@ -103,6 +126,14 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
assert String.contains?(result.thumbnail_filepath, "-thumb.webp")
end
test "doesn't include thumbnail if the file doesn't exist on-disk", %{metadata: metadata, filepath: filepath} do
File.rm(filepath)
result = Parser.parse_for_media_item(metadata)
assert result.thumbnail_filepath == nil
end
test "doesn't freak out if the media has no thumbnails", %{metadata: metadata} do
metadata = Map.put(metadata, "thumbnails", %{})
@@ -121,12 +152,29 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
end
describe "parse_for_media_item/1 when testing infojson metadata" do
setup %{metadata: metadata} do
infojson_filepath = metadata["infojson_filename"]
:ok = File.cp(infojson_filepath_fixture(), infojson_filepath)
on_exit(fn -> File.rm(infojson_filepath) end)
{:ok, filepath: infojson_filepath}
end
test "extracts the metadata filepath", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert String.ends_with?(result.metadata_filepath, ".info.json")
end
test "doesn't include metadata if the file doesn't exist on-disk", %{metadata: metadata, filepath: filepath} do
File.rm(filepath)
result = Parser.parse_for_media_item(metadata)
assert result.metadata_filepath == nil
end
test "doesn't freak out if the media has no infojson", %{metadata: metadata} do
metadata = Map.put(metadata, "infojson_filename", nil)
@@ -50,6 +50,23 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
end
end
describe "perform/1 when testing attribute updates" do
test "the source description is saved" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture(%{description: nil})
refute source.description
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
assert source.description == "This is a test file for Pinchflat"
end
end
describe "perform/1 when testing metadata storage" do
test "sets metadata location for source" do
stub(YtDlpRunnerMock, :run, fn
@@ -59,7 +76,6 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
source = Repo.preload(source_fixture(), :metadata)
refute source.metadata
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
@@ -83,6 +99,42 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
assert metadata == %{"title" => "test"}
end
test "sets metadata image location for source" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture()
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
assert source.metadata.fanart_filepath
assert source.metadata.poster_filepath
assert source.metadata.banner_filepath
Sources.delete_source(source, delete_files: true)
end
test "stores metadata images for source" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture()
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
assert File.exists?(source.metadata.fanart_filepath)
assert File.exists?(source.metadata.poster_filepath)
assert File.exists?(source.metadata.banner_filepath)
Sources.delete_source(source, delete_files: true)
end
end
describe "perform/1 when testing source image downloading" do
@@ -0,0 +1,64 @@
defmodule Pinchflat.Podcasts.PodcastHelpersTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
import Pinchflat.MediaFixtures
alias Pinchflat.Podcasts.PodcastHelpers
describe "persisted_media_items_for/2" do
test "returns media items with files that exist on-disk" do
source = source_fixture()
good_media = media_item_with_attachments(%{source_id: source.id})
_bad_media = media_item_fixture(%{source_id: source.id, media_filepath: "/tmp/existing_file.mp3"})
assert [persisted_media] = PodcastHelpers.persisted_media_items_for(source)
assert persisted_media.id == good_media.id
end
test "lets you specify a limit" do
source = source_fixture()
_good_media = media_item_with_attachments(%{source_id: source.id})
assert [] = PodcastHelpers.persisted_media_items_for(source, limit: 0)
end
end
describe "select_cover_image/2" do
test "returns a source's poster, if present" do
source = source_with_metadata_attachments()
{:ok, res} = PodcastHelpers.select_cover_image(source, [])
assert res == source.metadata.poster_filepath
end
test "falls back to a source's fanart, if present" do
source = source_with_metadata_attachments()
File.rm(source.metadata.poster_filepath)
{:ok, res} = PodcastHelpers.select_cover_image(source, [])
assert res == source.metadata.fanart_filepath
end
test "falls back to a media item's thumbnail, if present" do
source = source_with_metadata_attachments()
media_item = media_item_with_metadata_attachments(%{source_id: source.id})
File.rm(source.metadata.poster_filepath)
File.rm(source.metadata.fanart_filepath)
{:ok, res} = PodcastHelpers.select_cover_image(source, [media_item])
assert res == media_item.metadata.thumbnail_filepath
end
test "returns error if no artwork can be found" do
source = source_fixture()
assert PodcastHelpers.select_cover_image(source, []) == {:error, :no_suitable_image}
end
end
end
@@ -0,0 +1,145 @@
defmodule Pinchflat.Podcasts.RssFeedBuilderTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Podcasts.RssFeedBuilder
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
setup do
source = source_fixture()
{:ok, source: source}
end
describe "build/2" do
test "returns an XML document", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<?xml version="1.0" encoding="UTF-8"?>))
end
test "escapes illegal characters" do
source = source_fixture(%{custom_name: "A & B"})
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<title>A &amp; B</title>))
end
test "can optionally apply a limit to media items", %{source: source} do
good_media = media_item_with_attachments(%{source_id: source.id})
res = RssFeedBuilder.build(source, limit: 0)
refute String.contains?(res, ~s(<title>#{good_media.title}</title>))
end
test "can optionally specify a URL base", %{source: source} do
res = RssFeedBuilder.build(source, url_base: "http://example.com")
assert String.contains?(res, ~s(http://example.com/sources/#{source.uuid}/feed.xml))
end
end
describe "build/2 when testing source XML" do
test "returns XML for static source attributes", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<title>#{source.custom_name}</title>))
assert String.contains?(res, ~s(<link>#{source.original_url}</link>))
assert String.contains?(res, ~s(<description>#{source.description}</description>))
assert String.contains?(res, ~s(<itunes:author>#{source.custom_name}</itunes:author>))
assert String.contains?(res, ~s(<itunes:subtitle>#{source.custom_name}</itunes:subtitle>))
assert String.contains?(res, ~s(<description>#{source.description}</description>))
assert String.contains?(res, ~s(<podcast:guid>#{source.uuid}</podcast:guid>))
end
test "returns the lastBuildDate and pubDate based off the source's timestamps", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<lastBuildDate>#{format_date(source.updated_at)}</lastBuildDate>))
assert String.contains?(res, ~s(<pubDate>#{format_date(source.inserted_at)}</pubDate>))
end
test "returns a self-link", %{source: source} do
res = RssFeedBuilder.build(source)
assert String.contains?(
res,
~s(<atom:link href="http://localhost:8945/sources/#{source.uuid}/feed.xml" rel="self" type="application/rss+xml" />)
)
end
test "returns a link to the feed image" do
source = source_with_metadata_attachments()
res = RssFeedBuilder.build(source)
[_before, image_block, _after] = String.split(res, ~r(</?image>))
assert String.contains?(image_block, ~s(<url>http://localhost:8945/sources/#{source.uuid}/feed_image.jpg</url>))
assert String.contains?(image_block, ~s(<title>#{source.custom_name}</title>))
assert String.contains?(image_block, ~s(<link>#{source.original_url}</link>))
assert String.contains?(
res,
~s(<itunes:image href="http://localhost:8945/sources/#{source.uuid}/feed_image.jpg"></itunes:image>)
)
end
end
describe "build/2 when testing media XML" do
test "only includes media persisted to disk", %{source: source} do
good_media = media_item_with_attachments(%{source_id: source.id})
bad_media = media_item_fixture(%{source_id: source.id, media_filepath: "/tmp/existing_file.mp3"})
pending_media = media_item_fixture(%{source_id: source.id, media_filepath: nil})
res = RssFeedBuilder.build(source)
assert String.contains?(res, ~s(<title>#{good_media.title}</title>))
refute String.contains?(res, ~s(<title>#{bad_media.title}</title>))
refute String.contains?(res, ~s(<title>#{pending_media.title}</title>))
end
test "returns XML for static media attributes", %{source: source} do
media_item = media_item_with_attachments(%{source_id: source.id})
res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
assert String.contains?(item_xml, ~s(<guid isPermaLink="false">#{media_item.uuid}</guid>))
assert String.contains?(item_xml, ~s(<title>#{media_item.title}</title>))
assert String.contains?(item_xml, ~s(<link>#{media_item.original_url}</link>))
assert String.contains?(item_xml, ~s(<description>#{media_item.description}</description>))
assert String.contains?(item_xml, ~s(<itunes:author>#{source.custom_name}</itunes:author>))
assert String.contains?(item_xml, ~s(<itunes:subtitle>#{media_item.title}</itunes:subtitle>))
assert String.contains?(item_xml, ~s(<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>))
end
test "returns pubDate based off the media's upload_date", %{source: source} do
media_item_with_attachments(%{source_id: source.id, upload_date: ~D[2020-01-01]})
res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
assert String.contains?(item_xml, ~s(<pubDate>Wed, 01 Jan 2020 00:00:00 +0000</pubDate>))
end
test "returns an enclosure tag with the media's stream URL", %{source: source} do
media_item = media_item_with_attachments(%{source_id: source.id, media_size_bytes: 1234})
res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
assert String.contains?(item_xml, ~s(<enclosure))
assert String.contains?(item_xml, ~s(url="http://localhost:8945/media/#{media_item.uuid}/stream.mp4"))
assert String.contains?(item_xml, ~s(length="1234"))
assert String.contains?(item_xml, ~s(type="video/mp4"))
end
end
defp format_date(date) do
Calendar.strftime(date, @datetime_format)
end
end
+94
View File
@@ -59,6 +59,31 @@ defmodule Pinchflat.SourcesTest do
end
describe "create_source/2" do
test "automatically sets the UUID" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Source{} = source} = Sources.create_source(valid_attrs)
assert String.length(source.uuid) == 36
end
test "UUID is not writable by the user" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
uuid: "some_uuid"
}
assert {:ok, %Source{} = source} = Sources.create_source(valid_attrs)
assert String.length(source.uuid) == 36
end
test "creates a source and adds name + ID from runner response for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
@@ -112,6 +137,75 @@ defmodule Pinchflat.SourcesTest do
assert source.custom_name == "some channel name"
end
test "creation enforces uniqueness of collection_id scoped to the media_profile and title regex" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_once_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
title_filter_regex: nil
}
assert {:ok, %Source{}} = Sources.create_source(valid_once_attrs)
assert {:error, %Ecto.Changeset{}} = Sources.create_source(valid_once_attrs)
end
test "creation lets you duplicate collection_ids and profiles as long as the regex is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
name: "some name",
original_url: "https://www.youtube.com/channel/abc123"
}
source_1_attrs = Map.merge(valid_attrs, %{title_filter_regex: "foo"})
source_2_attrs = Map.merge(valid_attrs, %{title_filter_regex: "bar"})
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
assert {:ok, %Source{}} = Sources.create_source(source_2_attrs)
end
test "creation lets you duplicate collection_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_attrs = %{
name: "some name",
original_url: "https://www.youtube.com/channel/abc123",
title_filter_regex: "TEST"
}
source_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
source_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
assert {:ok, %Source{}} = Sources.create_source(source_2_attrs)
end
test "collection_type is inferred from source details" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
expect(YtDlpRunnerMock, :run, &playlist_mock/3)
@@ -0,0 +1,14 @@
defmodule Pinchflat.Utils.DatetimeUtilsTest do
use ExUnit.Case, async: true
alias Pinchflat.Utils.DatetimeUtils
describe "date_to_datetime/1" do
test "converts a Date to a DateTime" do
date = ~D[2022-01-01]
datetime = DatetimeUtils.date_to_datetime(date)
assert datetime == ~U[2022-01-01 00:00:00Z]
end
end
end
@@ -1,6 +1,8 @@
defmodule Pinchflat.YtDlp.CommandRunnerTest do
use ExUnit.Case, async: true
alias Pinchflat.Filesystem.FilesystemHelpers
alias Pinchflat.YtDlp.CommandRunner, as: Runner
@original_executable Application.compile_env(:pinchflat, :yt_dlp_executable)
@@ -65,6 +67,49 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
end
end
describe "run/4 when testing cookie options" do
setup do
base_dir = Application.get_env(:pinchflat, :extras_directory)
cookie_file = Path.join(base_dir, "cookies.txt")
{:ok, cookie_file: cookie_file}
end
test "includes cookie options when cookies.txt exists", %{cookie_file: cookie_file} do
FilesystemHelpers.write_p!(cookie_file, "cookie data")
assert {:ok, output} = Runner.run(@media_url, [], "")
assert String.contains?(output, "--cookies #{cookie_file}")
end
test "doesn't include cookie options when cookies.txt blank", %{cookie_file: cookie_file} do
FilesystemHelpers.write_p!(cookie_file, " \n \n ")
assert {:ok, output} = Runner.run(@media_url, [], "")
refute String.contains?(output, "--cookies")
refute String.contains?(output, cookie_file)
end
test "doesn't include cookie options when cookies.txt doesn't exist", %{cookie_file: cookie_file} do
File.rm(cookie_file)
assert {:ok, output} = Runner.run(@media_url, [], "")
refute String.contains?(output, "--cookies")
refute String.contains?(output, cookie_file)
end
end
describe "version/0" do
test "adds the version arg" do
assert {:ok, output} = Runner.version()
assert String.contains?(output, "--version")
end
end
defp wrap_executable(new_executable, fun) do
Application.put_env(:pinchflat, :yt_dlp_executable, new_executable)
fun.()
+24 -1
View File
@@ -104,7 +104,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk",
livestream: false,
short_form_content: false,
upload_date: Date.from_iso8601!("2021-01-01")
upload_date: Date.from_iso8601!("2021-01-01"),
duration_seconds: 60
} == Media.response_to_struct(response)
end
@@ -174,5 +175,27 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert %Media{upload_date: nil} = Media.response_to_struct(response)
end
test "parses the duration" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 60.4,
"upload_date" => "20210101"
}
assert %Media{duration_seconds: 60} = Media.response_to_struct(response)
end
test "doesn't blow up if duration is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => nil,
"upload_date" => "20210101"
}
assert %Media{duration_seconds: nil} = Media.response_to_struct(response)
end
end
end
@@ -0,0 +1,10 @@
defmodule PinchflatWeb.HealthControllerTest do
use PinchflatWeb.ConnCase
describe "GET /healthcheck" do
test "returns ok", %{conn: conn} do
conn = get(conn, "/healthcheck")
assert json_response(conn, 200) == %{"status" => "ok"}
end
end
end
@@ -10,27 +10,58 @@ defmodule PinchflatWeb.MediaItemControllerTest do
test "renders the page", %{conn: conn, media_item: media_item} do
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
assert html_response(conn, 200) =~ "Media Item ##{media_item.id}"
assert html_response(conn, 200) =~ "#{media_item.title}"
end
end
describe "delete media when just deleting the records" do
describe "edit media" do
setup [:create_media_item]
test "renders form for editing chosen media_item", %{conn: conn, media_item: media_item} do
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}/edit")
assert html_response(conn, 200) =~ "Editing"
end
end
describe "update media" do
setup [:create_media_item]
test "redirects when data is valid", %{conn: conn, media_item: media_item} do
update_attrs = %{title: "New Title"}
conn = put(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}", media_item: update_attrs)
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}/media/#{media_item}"
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
assert html_response(conn, 200) =~ update_attrs[:title]
end
test "renders errors when data is invalid", %{conn: conn, media_item: media_item} do
conn = put(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}", media_item: %{title: nil})
assert html_response(conn, 200) =~ "Editing"
end
end
describe "delete media" do
setup do
media_item = media_item_with_attachments()
%{media_item: media_item}
end
test "the media item is deleted", %{conn: conn, media_item: media_item} do
test "the media item not is deleted", %{conn: conn, media_item: media_item} do
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
assert Repo.reload!(media_item)
end
test "the files are not deleted", %{conn: conn, media_item: media_item} do
test "the files are deleted", %{conn: conn, media_item: media_item} do
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
assert File.exists?(media_item.media_filepath)
refute File.exists?(media_item.media_filepath)
end
test "redirects to the source page", %{conn: conn, media_item: media_item} do
@@ -38,31 +69,162 @@ defmodule PinchflatWeb.MediaItemControllerTest do
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
end
test "doesn't prevent re-download by default", %{conn: conn, media_item: media_item} do
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}")
media_item = Repo.reload(media_item)
refute media_item.prevent_download
end
test "can optionally prevent re-download", %{conn: conn, media_item: media_item} do
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?prevent_download=true")
media_item = Repo.reload(media_item)
assert media_item.prevent_download
end
end
describe "delete media when deleting the records and files" do
describe "streaming media" do
test "returns 404 if the media isn't found", %{conn: conn} do
media_item = media_item_fixture()
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert conn.status == 404
end
test "automatically sets the content type", %{conn: conn} do
media_item = media_item_with_attachments()
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert {"content-type", "video/mp4; charset=utf-8"} in conn.resp_headers
end
test "sets the content length", %{conn: conn} do
media_item = media_item_with_attachments()
filesize = File.stat!(media_item.media_filepath).size
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert {"content-length", to_string(filesize)} in conn.resp_headers
end
end
describe "streaming media when range is valid" do
setup do
media_item = media_item_with_attachments()
%{media_item: media_item}
end
test "the media item is deleted", %{conn: conn, media_item: media_item} do
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
test "sets the correct status and headers", %{conn: conn, media_item: media_item} do
filesize = File.stat!(media_item.media_filepath).size
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end
conn =
conn
|> put_req_header("range", "bytes=0-100")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.status == 206
assert {"content-range", "bytes 0-100/#{filesize}"} in conn.resp_headers
assert {"content-length", "101"} in conn.resp_headers
end
test "the files are deleted", %{conn: conn, media_item: media_item} do
delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
test "streams the specified range", %{conn: conn, media_item: media_item} do
conn =
conn
|> put_req_header("range", "bytes=0-100")
|> get(~p"/media/#{media_item.uuid}/stream")
refute File.exists?(media_item.media_filepath)
assert byte_size(conn.resp_body) == 101
end
test "redirects to the source page", %{conn: conn, media_item: media_item} do
conn = delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true")
test "supports range offsets", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
expected = String.slice(contents, 100..200)
assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}"
conn =
conn
|> put_req_header("range", "bytes=100-200")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == expected
end
test "returns as expected if the requested range is larger than the file", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
filesize = File.stat!(media_item.media_filepath).size
conn =
conn
|> put_req_header("range", "bytes=0-#{filesize * 10}")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == contents
assert {"content-range", "bytes 0-#{filesize - 1}/#{filesize}"} in conn.resp_headers
assert {"content-length", to_string(filesize)} in conn.resp_headers
end
test "supports endless ranges", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
conn =
conn
|> put_req_header("range", "bytes=0-")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == contents
end
test "supports endless ranges with offsets", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
{_, expected} = String.split_at(contents, 100)
conn =
conn
|> put_req_header("range", "bytes=100-")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == expected
end
end
describe "streaming media when range is invalid or not present" do
setup do
media_item = media_item_with_attachments()
%{media_item: media_item}
end
test "sets the correct status and headers", %{conn: conn, media_item: media_item} do
filesize = File.stat!(media_item.media_filepath).size
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert conn.status == 200
assert {"content-length", to_string(filesize)} in conn.resp_headers
end
test "streams the entire file", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
conn = get(conn, ~p"/media/#{media_item.uuid}/stream")
assert conn.resp_body == contents
end
test "doesn't blow up if the range header is invalid", %{conn: conn, media_item: media_item} do
contents = File.read!(media_item.media_filepath)
conn =
conn
|> put_req_header("range", "bytes=-")
|> get(~p"/media/#{media_item.uuid}/stream")
assert conn.status == 200
assert conn.resp_body == contents
end
end
@@ -0,0 +1,38 @@
defmodule PinchflatWeb.PodcastControllerTest do
use PinchflatWeb.ConnCase
import Pinchflat.SourcesFixtures
describe "rss_feed" do
test "renders the XML document", %{conn: conn} do
source = source_fixture()
conn = get(conn, ~p"/sources/#{source.uuid}/feed" <> ".xml")
assert conn.status == 200
assert {"content-type", "application/rss+xml; charset=utf-8"} in conn.resp_headers
assert {"content-disposition", "inline"} in conn.resp_headers
end
end
describe "feed_image" do
test "returns a feed image if one can be found", %{conn: conn} do
source = source_with_metadata_attachments()
conn = get(conn, ~p"/sources/#{source.uuid}/feed_image" <> ".jpg")
assert conn.status == 200
assert {"content-type", "image/jpeg; charset=utf-8"} in conn.resp_headers
assert conn.resp_body == File.read!(source.metadata.poster_filepath)
end
test "returns 404 if an image cannot be found", %{conn: conn} do
source = source_fixture()
conn = get(conn, ~p"/sources/#{source.uuid}/feed_image" <> ".jpg")
assert conn.status == 404
assert conn.resp_body == "Image not found"
end
end
end
+51
View File
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.RoutingTest do
use PinchflatWeb.ConnCase
import Pinchflat.SourcesFixtures
describe "basic_auth plug" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
@@ -54,4 +56,53 @@ defmodule PinchflatWeb.RoutingTest do
assert conn.status == 200
end
end
describe "maybe_basic_auth plug" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
old_password = Application.get_env(:pinchflat, :basic_auth_password)
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
source = source_fixture()
on_exit(fn ->
Application.put_env(:pinchflat, :basic_auth_username, old_username)
Application.put_env(:pinchflat, :basic_auth_password, old_password)
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
end)
{:ok, source: source}
end
test "uses basic auth when expose_feed_endpoints is false", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 401
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
end
test "does not use basic auth when expose_feed_endpoints is true", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 200
end
test "does not use basic auth when username/password aren't set", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, nil)
Application.put_env(:pinchflat, :basic_auth_password, nil)
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 200
end
end
end
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1513,7 +1513,7 @@
"url": "https://i.ytimg.com/vi_webp/ABC123/maxresdefault.webp",
"preference": 0,
"id": "41",
"filepath": "/app/tmp/videos/ABC123/Pinchflat Example Video-ABC123.webp"
"filepath": "/tmp/test/tmpfiles/Pinchflat Example Video-ABC123.webp"
}
],
"thumbnail": "https://i.ytimg.com/vi/ABC123/maxresdefault.jpg",
@@ -9769,7 +9769,7 @@
"release_git_head": "5498729c59b03a9511c64552da3ba2f802166f8d",
"repository": "yt-dlp/yt-dlp-nightly-builds"
},
"infojson_filename": "/app/tmp/videos/ABC123/Pinchflat Example Video-ABC123.info.json",
"infojson_filename": "/tmp/test/tmpfiles/Pinchflat Example Video-ABC123.info.json",
"__infojson_filename": "/app/tmp/videos/ABC123/Pinchflat Example Video-ABC123.info.json",
"__real_download": true,
"__files_to_merge": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

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