├── .editorconfig ├── .github └── workflows │ └── scala.yml ├── .gitignore ├── .jvmopts ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── core └── src │ └── main │ ├── resources │ ├── application.properties │ └── question.png │ └── scala │ └── podpodge │ ├── CreateEpisodeRequest.scala │ ├── DownloadWorker.scala │ ├── Main.scala │ ├── PodpodgeLogging.scala │ ├── RssFormat.scala │ ├── StaticConfig.scala │ ├── config │ └── package.scala │ ├── controllers │ ├── ConfigurationController.scala │ ├── EpisodeController.scala │ └── PodcastController.scala │ ├── db │ ├── Configuration.scala │ ├── DbMigration.scala │ ├── Episode.scala │ ├── Podcast.scala │ ├── dao │ │ ├── ConfigurationDao.scala │ │ ├── EpisodeDao.scala │ │ ├── PodcastDao.scala │ │ └── SqlDao.scala │ └── patch │ │ └── PatchConfiguration.scala │ ├── http │ ├── ApiError.scala │ ├── HttpError.scala │ ├── PekkoHttp.scala │ └── Sttp.scala │ ├── package.scala │ ├── rss │ ├── Episode.scala │ └── Podcast.scala │ ├── server │ ├── PodpodgeServer.scala │ ├── RawRoutes.scala │ ├── Routes.scala │ └── TapirSupport.scala │ ├── types │ ├── SourceType.scala │ ├── Tristate.scala │ └── package.scala │ ├── util │ └── FileExtensions.scala │ └── youtube │ ├── PlaylistItemListResponse.scala │ ├── PlaylistListResponse.scala │ ├── YouTubeClient.scala │ └── YouTubeDL.scala ├── flyway.conf ├── migration ├── V1__create-tables.sql ├── V2__source-type.sql ├── V3__configuration.sql └── V4__configuration-downloader.sql └── project ├── Build.scala ├── Plugins.scala ├── Version.scala ├── build.properties └── plugins.sbt /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up JDK 11 17 | uses: actions/setup-java@v1 18 | with: 19 | java-version: 11 20 | 21 | - name: Run tests 22 | run: sbt -Dscalac.lenientDev.enabled=false fmtCheck test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | .idea_modules 4 | .bloop 5 | .bsp 6 | .metals 7 | /.classpath 8 | /.project 9 | /.settings 10 | /RUNNING_PID 11 | /out/ 12 | *.iws 13 | *.iml 14 | /db 15 | .eclipse 16 | /lib/ 17 | /logs/ 18 | /modules 19 | tmp/ 20 | test-result 21 | server.pid 22 | *.eml 23 | /dist/ 24 | .cache 25 | /reference 26 | local.conf 27 | /logs 28 | /data/assets/audio 29 | /data/assets/images 30 | /data/podpodge.db 31 | /data/custom 32 | publish.sbt -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -Dfile.encoding=UTF-8 2 | -Xmx4g 3 | -XX:MaxMetaspaceSize=1g 4 | -XX:MaxInlineLevel=20 5 | -Dquill.macro.log=false -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.7.17 2 | 3 | runner.dialect = scala213source3 4 | 5 | align = none 6 | maxColumn = 120 # For wide displays. 7 | assumeStandardLibraryStripMargin = true 8 | align.tokens = [{code = "=>", owner = "Case"}, {code = "<-"}] 9 | align.allowOverflow = true 10 | align.arrowEnumeratorGenerator = true 11 | align.openParenCallSite = false 12 | align.openParenDefnSite = false 13 | newlines.topLevelStatementBlankLines = [ 14 | {blanks {before = 1}} 15 | {regex = "^Import|^Term.ApplyInfix"} 16 | ] 17 | newlines.alwaysBeforeElseAfterCurlyIf = false 18 | indentOperator.topLevelOnly = true 19 | docstrings.style = SpaceAsterisk 20 | docstrings.wrapMaxColumn = 80 21 | 22 | includeCurlyBraceInSelectChains = false 23 | includeNoParensInSelectChains = false 24 | 25 | rewrite.rules = [SortModifiers, PreferCurlyFors, SortImports, RedundantBraces] 26 | rewrite.scala3.convertToNewSyntax = true 27 | rewrite.imports.groups = [ 28 | [".*"], 29 | ["java\\..*", "javax\\..*", "scala\\..*"] 30 | ] 31 | 32 | project.excludeFilters = ["/target/"] 33 | 34 | lineEndings = preserve 35 | 36 | fileOverride { 37 | "glob:**/*.sbt" { 38 | runner.dialect = sbt1 39 | rewrite.scala3.convertToNewSyntax = false 40 | } 41 | "glob:**/project/*.scala" { 42 | rewrite.scala3.convertToNewSyntax = false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podpodge 2 | 3 | ![Scala CI](https://github.com/reibitto/podpodge/actions/workflows/scala.yml/badge.svg) 4 | 5 | ## What is it? 6 | 7 | Podpodge is a server + client for converting YouTube playlists (or plain audio files in a directory) into audio-only RSS 8 | feeds that podcast apps can consume. 9 | 10 | Podpodge is written using [pekko-http](https://pekko.apache.org/docs/pekko-http/current/) + 11 | [tapir](https://tapir.softwaremill.com) + [ZIO](https://zio.dev) + [Quill](https://getquill.io/). It's still a work in 12 | progress in the sense that it doesn't have the nicest front-end yet (a Scala.js + [Slinky](https://slinky.dev/) 13 | front-end will be coming). Though it does have built-in Swagger integration so that you don't have to construct the API 14 | requests yourself for interacting with the DB and getting the RSS feed. 15 | 16 | ## Requirements 17 | 18 | - You need to obtain a [YouTube API Key](https://developers.google.com/youtube/registering_an_application) and set 19 | the `PODPODGE_YOUTUBE_API_KEY` environment variable. 20 | - [youtube-dl](https://github.com/ytdl-org/youtube-dl) or [yt-dlp](https://github.com/yt-dlp/yt-dlp) must be installed (there's an [open issue](https://github.com/reibitto/podpodge/issues/6) for automatically downloading it) 21 | 22 | _* The above are only requirements if `sourceType` is `youTube`. For `directory` you can ignore this._ 23 | 24 | ## Usage 25 | 26 | Run the server either using sbt (`sbt run`) or create an executable jar (`sbt assembly`) and run that. This will run the 27 | Podpodge server at http://localhost:8080 by default (this can be changed with `PODPODGE_HOST` and `PODPODGE_PORT`). For 28 | example, you might want to change `PODPODGE_HOST` to your network IP (like 192.168.1.100 or whatever it's set to) so that 29 | you can access it from your phone on the same local network. Of course the other option is to host it on a "proper" public 30 | server so that you can access it from anywhere. 31 | 32 | To register a YouTube playlist as a Podcast, call the `POST /podcast/{sourceType}` route (where `sourceType` can be set 33 | to `youTube` or `directory`). You can do this with the built-in Swagger integration (which is the default top-level page). 34 | 35 | The playlist ID is what appears in the address bar when visiting a YouTube playlist page, like https://www.youtube.com/playlist?list=YOUTUBE_PLAYLIST_ID 36 | 37 | *Note:* Private playlists aren't supported (might be possible after [this issue](https://github.com/reibitto/podpodge/issues/1) is addressed). Using unlisted playlists is the closest alternative for now. 38 | 39 | If successful, this should return you a JSON response of the Podcast. You can then use the `POST /podcasts/check` route to check for new episodes: 40 | 41 | (*Note:* There is an [issue](https://github.com/reibitto/podpodge/issues/8) for setting up CRON-like schedules per Podcast for automatic checks) 42 | 43 | Once that's done, you can access the RSS feed URL and put it into whatever podcast app you use. It'll look something like this (the ID may be different if you have multiple podcasts): 44 | http://localhost:8080/podcast/1/rss 45 | 46 | ## Contributing 47 | 48 | Podpodge is fairly barebones and I mainly made it for myself because similar apps I tried at the time didn't quite work for me. 49 | Plus, this was an exercise to learn how akka-http/pekko-http + ZIO + Quill (and eventually Slinky) work together. There are a bunch 50 | more features that could potentially be added and I created some issues for those. Feel free to take any if you'd like. 51 | Contributions are always welcome! 52 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import sbt.Keys._ 3 | import sbtwelcome._ 4 | 5 | lazy val root = project 6 | .in(file(".")) 7 | .aggregate(core) 8 | .settings( 9 | name := "podpodge", 10 | addCommandAlias("run", "podpodge/run"), 11 | addCommandAlias("fmt", "all root/scalafmtSbt root/scalafmtAll"), 12 | addCommandAlias("fmtCheck", "all root/scalafmtSbtCheck root/scalafmtCheckAll"), 13 | logo := 14 | s""" 15 | | ____ __ __ 16 | | / __ \\____ ____/ /___ ____ ____/ /___ ____ 17 | | / /_/ / __ \\/ __ / __ \\/ __ \\/ __ / __ `/ _ \\ 18 | | / ____/ /_/ / /_/ / /_/ / /_/ / /_/ / /_/ / __/ 19 | |/_/ \\____/\\__,_/ .___/\\____/\\__,_/\\__, /\\___/ 20 | | /_/ /____/ 21 | | 22 | |""".stripMargin, 23 | usefulTasks := Seq( 24 | UsefulTask("run", "Runs the Podpodge server"), 25 | UsefulTask("~podpodge/reStart", "Runs the Podpodge server with file-watch enabled"), 26 | UsefulTask("~compile", "Compile all modules with file-watch enabled"), 27 | UsefulTask("fmt", "Run scalafmt on the entire project") 28 | ) 29 | ) 30 | 31 | lazy val core = module("podpodge", Some("core")) 32 | .settings( 33 | fork := true, 34 | run / baseDirectory := file("."), 35 | reStart / baseDirectory := file("."), 36 | libraryDependencies ++= Seq( 37 | "dev.zio" %% "zio" % V.zio, 38 | "dev.zio" %% "zio-streams" % V.zio, 39 | "dev.zio" %% "zio-logging" % V.zioLogging, 40 | "dev.zio" %% "zio-process" % V.zioProcess, 41 | "dev.zio" %% "zio-prelude" % V.zioPrelude, 42 | "org.scala-lang.modules" %% "scala-xml" % V.scalaXml, 43 | "com.beachape" %% "enumeratum" % V.enumeratum, 44 | "com.beachape" %% "enumeratum-circe" % V.enumeratum, 45 | "io.circe" %% "circe-core" % V.circe, 46 | "io.circe" %% "circe-parser" % V.circe, 47 | "io.circe" %% "circe-generic" % V.circe, 48 | "org.apache.pekko" %% "pekko-http" % V.pekkoHttp, 49 | "org.apache.pekko" %% "pekko-actor-typed" % V.pekko, 50 | "org.apache.pekko" %% "pekko-stream" % V.pekko, 51 | "com.softwaremill.sttp.client3" %% "core" % V.sttp, 52 | "com.softwaremill.sttp.client3" %% "circe" % V.sttp, 53 | "com.softwaremill.sttp.client3" %% "zio" % V.sttp, 54 | "com.softwaremill.sttp.tapir" %% "tapir-enumeratum" % V.tapir, 55 | "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % V.tapir, 56 | "com.softwaremill.sttp.tapir" %% "tapir-pekko-http-server" % V.tapir, 57 | "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % V.tapir, 58 | "io.getquill" %% "quill-jdbc-zio" % V.quill, 59 | "org.xerial" % "sqlite-jdbc" % V.sqliteJdbc, 60 | "org.flywaydb" % "flyway-core" % V.flyway, 61 | "org.slf4j" % "slf4j-nop" % V.slf4j 62 | ) 63 | ) 64 | 65 | def module(projectId: String, moduleFile: Option[String] = None): Project = 66 | Project(id = projectId, base = file(moduleFile.getOrElse(projectId))) 67 | .settings(Build.defaultSettings(projectId)) 68 | -------------------------------------------------------------------------------- /core/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | ctx.driverClassName=org.sqlite.JDBC 2 | ctx.jdbcUrl=jdbc:sqlite:data/podpodge.db 3 | -------------------------------------------------------------------------------- /core/src/main/resources/question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reibitto/podpodge/ec475959dd475c7ba4af23d339b5ef14543d0af1/core/src/main/resources/question.png -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/CreateEpisodeRequest.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import podpodge.types.PodcastId 4 | import podpodge.youtube.PlaylistItem 5 | 6 | import java.io.File as JFile 7 | 8 | sealed trait CreateEpisodeRequest 9 | 10 | object CreateEpisodeRequest { 11 | final case class YouTube(podcastId: PodcastId, playlistItem: PlaylistItem) extends CreateEpisodeRequest 12 | final case class File(podcastId: PodcastId, file: JFile) extends CreateEpisodeRequest 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/DownloadWorker.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import podpodge.db.dao.EpisodeDao 4 | import podpodge.db.Episode 5 | import podpodge.http.Sttp 6 | import podpodge.types.* 7 | import sttp.client3.* 8 | import sttp.model.Uri 9 | import zio.* 10 | import zio.stream.ZStream 11 | 12 | import java.time.{Instant, ZoneOffset} 13 | import javax.sql.DataSource 14 | 15 | object DownloadWorker { 16 | 17 | def make(queue: Queue[CreateEpisodeRequest]): URIO[DataSource & Sttp, Unit] = 18 | ZStream 19 | .fromQueue(queue) 20 | .foreach { request => 21 | val create = request match { 22 | case r: CreateEpisodeRequest.YouTube => createEpisodeYouTube(r) 23 | case r: CreateEpisodeRequest.File => createEpisodeFile(r) 24 | } 25 | 26 | create.absorb.tapErrorCause { t => 27 | ZIO.logErrorCause(s"Error creating episode for $request", t) 28 | }.ignore 29 | } 30 | 31 | def createEpisodeYouTube( 32 | request: CreateEpisodeRequest.YouTube 33 | ): ZIO[Sttp & DataSource, Throwable, Unit] = { 34 | val videoId = request.playlistItem.snippet.resourceId.videoId 35 | 36 | for { 37 | episode <- EpisodeDao.create( 38 | Episode( 39 | EpisodeId.empty, 40 | request.podcastId, 41 | videoId, 42 | videoId, 43 | request.playlistItem.snippet.title, 44 | request.playlistItem.snippet.publishedAt, // TODO: Add config option to select `request.playlistItem.contentDetails.videoPublishedAt` here 45 | None, 46 | None, 47 | 0.seconds // TODO: Calculate duration 48 | ) 49 | ) 50 | _ <- ZIO.foreachDiscard(request.playlistItem.snippet.thumbnails.highestRes) { thumbnail => 51 | for { 52 | uri <- ZIO.fromEither(Uri.parse(thumbnail.url)).catchAll(ZIO.dieMessage(_)) 53 | req = basicRequest 54 | .get(uri) 55 | .response( 56 | asPath( 57 | StaticConfig.thumbnailsPath 58 | .resolve(request.podcastId.unwrap.toString) 59 | .resolve(s"${episode.id}.jpg") 60 | ) 61 | ) 62 | 63 | downloadedThumbnail <- Sttp.send(req) 64 | _ <- ZIO.whenCase(downloadedThumbnail.body) { case Right(_) => 65 | EpisodeDao.updateImage(episode.id, Some(s"${episode.id}.jpg")) 66 | } 67 | } yield () 68 | } 69 | } yield () 70 | } 71 | 72 | def createEpisodeFile( 73 | request: CreateEpisodeRequest.File 74 | ): RIO[Sttp & DataSource, Unit] = 75 | for { 76 | _ <- EpisodeDao.create( 77 | Episode( 78 | EpisodeId.empty, 79 | request.podcastId, 80 | request.file.getCanonicalPath, 81 | request.file.getPath, 82 | request.file.getName, 83 | Instant.ofEpochMilli(request.file.lastModified()).atOffset(ZoneOffset.UTC), 84 | None, 85 | None, 86 | 0.seconds // TODO: Calculate duration 87 | ) 88 | ) 89 | } yield () 90 | } 91 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/Main.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import io.getquill.jdbczio.Quill 4 | import podpodge.config.Config 5 | import podpodge.db.DbMigration 6 | import podpodge.http.SttpLive 7 | import podpodge.server.PodpodgeServer 8 | import zio.* 9 | 10 | object Main extends ZIOApp { 11 | override type Environment = Env 12 | 13 | val environmentTag: EnvironmentTag[Environment] = EnvironmentTag[Environment] 14 | 15 | override def bootstrap: ZLayer[ZIOAppArgs, Any, Environment] = 16 | ZLayer.fromZIO( 17 | StaticConfig.ensureDirectoriesExist *> 18 | DbMigration.migrate 19 | ) >>> 20 | ZLayer.make[Environment]( 21 | SttpLive.make, 22 | Quill.DataSource.fromPrefix("ctx"), 23 | Config.live, 24 | Runtime.removeDefaultLoggers >>> PodpodgeLogging.default 25 | ) 26 | 27 | def run: ZIO[Env & ZIOAppArgs & Scope, Throwable, Unit] = 28 | for { 29 | _ <- PodpodgeServer.make 30 | _ <- ZIO.never 31 | } yield () 32 | } 33 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/PodpodgeLogging.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import zio.logging.consoleLogger 4 | import zio.logging.ConsoleLoggerConfig 5 | import zio.logging.LogColor 6 | import zio.logging.LogFilter 7 | import zio.logging.LogFormat 8 | import zio.logging.LogFormat.* 9 | import zio.LogLevel 10 | import zio.ZLayer 11 | 12 | object PodpodgeLogging { 13 | 14 | val coloredFormat: LogFormat = 15 | timestamp.color(LogColor.BLUE) |-| 16 | level.highlight |-| 17 | fiberId.color(LogColor.WHITE) |-| 18 | line.highlight |-| 19 | newLine + 20 | cause.highlight.filter(LogFilter.causeNonEmpty) 21 | 22 | val default: ZLayer[Any, Nothing, Unit] = 23 | consoleLogger(ConsoleLoggerConfig(coloredFormat, LogFilter.logLevel(LogLevel.Debug))) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/RssFormat.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import podpodge.rss.Podcast 4 | 5 | import java.time.format.DateTimeFormatter 6 | import scala.xml.Elem 7 | 8 | object RssFormat { 9 | 10 | def encode(podcast: Podcast): Elem = 11 | 12 | 13 | {podcast.title} 14 | {podcast.linkUrl} 15 | {podcast.description} 16 | {podcast.category} 17 | {podcast.generator} 18 | en-us 19 | {podcast.lastBuildDate.format(DateTimeFormatter.RFC_1123_DATE_TIME)} 20 | {podcast.publishDate.format(DateTimeFormatter.RFC_1123_DATE_TIME)} 21 | 22 | {podcast.imageUrl.toString} 23 | {podcast.title} 24 | {podcast.linkUrl} 25 | 26 | {podcast.title} 27 | {podcast.title} 28 | {podcast.title} 29 | 30 | no 31 | { 32 | podcast.items.zipWithIndex.map { case (item, order) => 33 | 34 | {item.guid} 35 | {item.title} 36 | {item.linkUrl} 37 | 38 | {item.publishDate.format(DateTimeFormatter.RFC_1123_DATE_TIME)} 39 | 40 | {podcast.title} 41 | {item.title} 42 | 43 | 44 | {item.duration.toSeconds} 45 | no 46 | {order} 47 | 48 | } 49 | } 50 | 51 | 52 | } 53 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/StaticConfig.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import zio.{Task, ZIO} 4 | 5 | import java.nio.file.{Files, Path, Paths} 6 | 7 | object StaticConfig { 8 | val assetsPath: Path = Paths.get("data/assets") 9 | val audioPath: Path = assetsPath.resolve("audio") 10 | val imagesPath: Path = assetsPath.resolve("images") 11 | val coversPath: Path = imagesPath.resolve("covers") 12 | val thumbnailsPath: Path = imagesPath.resolve("thumbnails") 13 | 14 | def ensureDirectoriesExist: Task[Unit] = ZIO.attempt { 15 | Files.createDirectories(audioPath) 16 | Files.createDirectories(imagesPath) 17 | Files.createDirectories(coversPath) 18 | Files.createDirectories(thumbnailsPath) 19 | }.unit 20 | } 21 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/config/package.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import podpodge.db.dao.ConfigurationDao 4 | import podpodge.types.* 5 | import sttp.model.Uri 6 | import zio.* 7 | 8 | package object config { 9 | type Config = PodpodgeConfig 10 | 11 | object Config { 12 | 13 | def live = 14 | ZLayer { 15 | // TODO: This code is repetitious and somewhat brittle (newtypes are adding some safety though). We really 16 | // should use a config library here. Something like ciris, pureconfig, zio-config, etc. I haven't decided on one 17 | // yet. 18 | for { 19 | dbConfig <- ConfigurationDao.getPrimary.orDie 20 | youTubeApiKey <- 21 | ZIO 22 | .fromOption(dbConfig.youTubeApiKey) 23 | .orElse( 24 | System.env(YouTubeApiKey.configKey).some.flatMap(s => ZIO.fromOption(YouTubeApiKey.make(s).toOption)) 25 | ) 26 | .option 27 | serverHost <- 28 | ZIO 29 | .fromOption(dbConfig.serverHost) 30 | .orElse( 31 | System.env(ServerHost.configKey).some.flatMap(s => ZIO.fromOption(ServerHost.make(s).toOption)) 32 | ) 33 | .orElseSucceed(ServerHost("localhost")) 34 | serverPort <- 35 | ZIO 36 | .fromOption(dbConfig.serverPort) 37 | .orElse( 38 | System 39 | .env(ServerPort.configKey) 40 | .some 41 | .flatMap(s => ZIO.fromOption(s.toIntOption.flatMap(n => ServerPort.make(n).toOption))) 42 | ) 43 | .orElseSucceed(ServerPort(8080)) 44 | serverScheme <- 45 | ZIO 46 | .fromOption(dbConfig.serverScheme) 47 | .orElse( 48 | System.env(ServerScheme.configKey).some.flatMap(s => ZIO.fromOption(ServerScheme.make(s).toOption)) 49 | ) 50 | .orElseSucceed(ServerScheme("http")) 51 | downloaderPath <- 52 | ZIO 53 | .fromOption(dbConfig.downloaderPath) 54 | .orElse( 55 | System.env(DownloaderPath.configKey).some.flatMap(s => ZIO.fromOption(DownloaderPath.make(s).toOption)) 56 | ) 57 | .orElseSucceed(DownloaderPath("youtube-dl")) 58 | } yield PodpodgeConfig( 59 | youTubeApiKey, 60 | serverHost, 61 | serverPort, 62 | serverScheme, 63 | downloaderPath 64 | ) 65 | } 66 | } 67 | 68 | case class PodpodgeConfig( 69 | youTubeApiKey: Option[YouTubeApiKey], 70 | serverHost: ServerHost, 71 | serverPort: ServerPort, 72 | serverScheme: ServerScheme, 73 | downloaderPath: DownloaderPath 74 | ) { 75 | val baseUri: Uri = Uri(serverScheme.unwrap, serverHost.unwrap, serverPort.unwrap) 76 | } 77 | 78 | def get: URIO[Config, PodpodgeConfig] = ZIO.service[PodpodgeConfig] 79 | 80 | def youTubeApiKey: RIO[Config, YouTubeApiKey] = 81 | ZIO 82 | .serviceWith[Config](_.youTubeApiKey) 83 | .someOrFail( 84 | new Exception( 85 | s"${YouTubeApiKey.configKey} is empty but it's required for this operation. You can set it as an environment variable or in the `configuration` table." 86 | ) 87 | ) 88 | 89 | } 90 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/controllers/ConfigurationController.scala: -------------------------------------------------------------------------------- 1 | package podpodge.controllers 2 | 3 | import podpodge.config as _ 4 | import podpodge.db.dao.ConfigurationDao 5 | import podpodge.db.patch.PatchConfiguration 6 | import podpodge.db.Configuration 7 | import podpodge.types.ConfigurationId 8 | import zio.* 9 | 10 | import javax.sql.DataSource 11 | 12 | object ConfigurationController { 13 | 14 | def getPrimary: RIO[DataSource, Configuration.Model] = 15 | ConfigurationDao.getPrimary 16 | 17 | def patch(id: ConfigurationId, model: PatchConfiguration): RIO[DataSource, Configuration.Model] = 18 | ConfigurationDao.patch(id, model) 19 | 20 | } 21 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/controllers/EpisodeController.scala: -------------------------------------------------------------------------------- 1 | package podpodge.controllers 2 | 3 | import org.apache.pekko.http.scaladsl.model.{HttpEntity, MediaType, StatusCodes} 4 | import org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectives.ResourceFile 5 | import org.apache.pekko.stream.scaladsl.{FileIO, Source, StreamConverters} 6 | import org.apache.pekko.stream.IOResult 7 | import org.apache.pekko.util.ByteString 8 | import podpodge.db.dao.{ConfigurationDao, EpisodeDao, PodcastDao} 9 | import podpodge.db.Episode 10 | import podpodge.http.HttpError 11 | import podpodge.types.* 12 | import podpodge.youtube.YouTubeDL 13 | import podpodge.StaticConfig 14 | import zio.* 15 | 16 | import java.io.File 17 | import java.nio.file.Paths 18 | import javax.sql.DataSource 19 | import scala.concurrent.Future 20 | 21 | object EpisodeController { 22 | 23 | def getEpisodeFile(id: EpisodeId): RIO[DataSource, HttpEntity.Default] = 24 | for { 25 | episode <- EpisodeDao.get(id).someOrFail(HttpError(StatusCodes.NotFound)) 26 | file <- 27 | ZIO 28 | .succeed( 29 | StaticConfig.audioPath 30 | .resolve(episode.podcastId.unwrap.toString) 31 | .resolve(s"${episode.externalSource}.mp3") 32 | .toFile 33 | ) 34 | .filterOrFail(_.exists)(HttpError(StatusCodes.NotFound)) 35 | } yield HttpEntity.Default( 36 | MediaType.audio("mpeg", MediaType.NotCompressible, "mp3"), 37 | file.length, 38 | FileIO.fromPath(file.toPath) 39 | ) 40 | 41 | def getEpisodeFileOnDemand( 42 | episodesDownloading: Ref.Synchronized[Map[EpisodeId, Promise[Throwable, File]]] 43 | )(id: EpisodeId): RIO[DataSource, HttpEntity.Default] = 44 | for { 45 | episode <- EpisodeDao.get(id).someOrFail(HttpError(StatusCodes.NotFound)) 46 | podcast <- PodcastDao.get(episode.podcastId).someOrFail(HttpError(StatusCodes.NotFound)) 47 | _ <- ZIO.logInfo(s"Requested episode '${episode.title}' on demand") 48 | result <- podcast.sourceType match { 49 | case SourceType.YouTube => 50 | getEpisodeFileOnDemandYouTube(episodesDownloading)(episode) 51 | 52 | case SourceType.Directory => 53 | val mediaPath = Paths.get(episode.externalSource) 54 | 55 | ZIO.succeed( 56 | HttpEntity.Default( 57 | MediaType.audio("mpeg", MediaType.NotCompressible, "mp3"), 58 | mediaPath.toFile.length, 59 | FileIO.fromPath(mediaPath) 60 | ) 61 | ) 62 | } 63 | } yield result 64 | 65 | def getEpisodeFileOnDemandYouTube( 66 | episodesDownloading: Ref.Synchronized[Map[EpisodeId, Promise[Throwable, File]]] 67 | )(episode: Episode.Model): RIO[DataSource, HttpEntity.Default] = 68 | for { 69 | config <- ConfigurationDao.getPrimary 70 | promiseMap <- episodesDownloading.updateAndGetZIO { downloadMap => 71 | downloadMap.get(episode.id) match { 72 | case None => 73 | for { 74 | p <- Promise.make[Throwable, File] 75 | _ <- YouTubeDL 76 | .download(episode.podcastId, episode.externalSource, config.downloaderPath) 77 | .onExit { e => 78 | e.toEither.fold(p.fail, p.succeed) *> 79 | episodesDownloading.updateAndGetZIO(m => ZIO.succeed(m - episode.id)) 80 | } 81 | .forkDaemon 82 | } yield downloadMap + (episode.id -> p) 83 | 84 | case Some(_) => ZIO.succeed(downloadMap) 85 | } 86 | } 87 | mediaFile <- promiseMap(episode.id).await 88 | _ <- EpisodeDao.updateMediaFile(episode.id, Some(mediaFile.getName)) 89 | } yield HttpEntity.Default( 90 | MediaType.audio("mpeg", MediaType.NotCompressible, "mp3"), 91 | mediaFile.length, 92 | FileIO.fromPath(mediaFile.toPath) 93 | ) 94 | 95 | def getThumbnail(id: EpisodeId): RIO[DataSource, Source[ByteString, Future[IOResult]]] = 96 | for { 97 | episode <- EpisodeDao.get(id).someOrFail(HttpError(StatusCodes.NotFound)) 98 | result <- episode.imagePath.map(_.toFile) match { 99 | case Some(imageFile) if imageFile.exists() => 100 | ZIO.succeed(FileIO.fromPath(imageFile.toPath)) 101 | 102 | case _ => 103 | Option(getClass.getResource("/question.png")).flatMap(ResourceFile.apply) match { 104 | case None => ZIO.fail(HttpError(StatusCodes.InternalServerError)) 105 | case Some(resource) => 106 | ZIO.succeed(StreamConverters.fromInputStream(() => resource.url.openStream())) 107 | } 108 | } 109 | } yield result 110 | 111 | } 112 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/controllers/PodcastController.scala: -------------------------------------------------------------------------------- 1 | package podpodge.controllers 2 | 3 | import org.apache.pekko.http.scaladsl.model.StatusCodes 4 | import org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectives.ResourceFile 5 | import org.apache.pekko.stream.scaladsl.{FileIO, Source, StreamConverters} 6 | import org.apache.pekko.stream.IOResult 7 | import org.apache.pekko.util.ByteString 8 | import podpodge.* 9 | import podpodge.config.Config 10 | import podpodge.db.dao.{EpisodeDao, PodcastDao} 11 | import podpodge.db.Podcast 12 | import podpodge.db.Podcast.Model 13 | import podpodge.http.{ApiError, HttpError, Sttp} 14 | import podpodge.types.{PodcastId, SourceType} 15 | import podpodge.youtube.YouTubeClient 16 | import sttp.client3.* 17 | import sttp.model.Uri 18 | import zio.* 19 | import zio.stream.ZStream 20 | 21 | import java.nio.file.{Files, Paths} 22 | import java.sql.SQLException 23 | import javax.sql.DataSource 24 | import scala.concurrent.Future 25 | import scala.xml.Elem 26 | 27 | object PodcastController { 28 | 29 | def getPodcast(id: PodcastId): RIO[DataSource, Model] = 30 | PodcastDao.get(id).someOrFail(ApiError.NotFound(s"Podcast $id does not exist.")) 31 | 32 | def listPodcasts: ZIO[DataSource, SQLException, List[Model]] = PodcastDao.list 33 | 34 | def getPodcastRss(id: PodcastId): RIO[DataSource & Config, Elem] = 35 | for { 36 | config <- config.get 37 | podcast <- PodcastDao.get(id).someOrFail(ApiError.NotFound(s"Podcast $id does not exist.")) 38 | episodes <- EpisodeDao.listByPodcast(id) 39 | } yield RssFormat.encode(rss.Podcast.fromDB(podcast, episodes, config)) 40 | 41 | def getPodcastCover( 42 | id: PodcastId 43 | ): ZIO[DataSource, Exception, Source[ByteString, Future[IOResult]]] = 44 | for { 45 | podcast <- PodcastDao.get(id).someOrFail(HttpError(StatusCodes.NotFound)) 46 | result <- podcast.imagePath.map(_.toFile) match { 47 | case Some(imageFile) if imageFile.exists() => 48 | ZIO.succeed(FileIO.fromPath(imageFile.toPath)) 49 | 50 | case _ => 51 | Option(getClass.getResource("/question.png")).flatMap(ResourceFile.apply) match { 52 | case None => ZIO.fail(HttpError(StatusCodes.InternalServerError)) 53 | case Some(resource) => 54 | ZIO.succeed(StreamConverters.fromInputStream(() => resource.url.openStream())) 55 | } 56 | } 57 | } yield result 58 | 59 | def create( 60 | sourceType: SourceType, 61 | sources: List[String] 62 | ): RIO[Sttp & DataSource & Config, List[Podcast.Model]] = 63 | sourceType match { 64 | case SourceType.YouTube => 65 | for { 66 | youTubeApiKey <- config.youTubeApiKey 67 | playlists <- YouTubeClient.listPlaylists(sources, youTubeApiKey).runCollect 68 | _ <- ZIO.when(playlists.isEmpty) { 69 | ZIO.fail( 70 | ApiError.BadRequest( 71 | "No playlists found. Are you sure you marked them as unlisted or public rather than private? Currently private playlists are not supported." 72 | ) 73 | ) 74 | } 75 | podcasts <- PodcastDao.createAll(playlists.toList.map(Podcast.fromPlaylist)) 76 | podcastsWithPlaylists = podcasts.zip(playlists) 77 | _ <- ZIO.foreachDiscard(podcastsWithPlaylists) { case (podcast, playlist) => 78 | ZIO.foreachDiscard(playlist.snippet.thumbnails.highestRes) { thumbnail => 79 | for { 80 | uri <- ZIO.fromEither(Uri.parse(thumbnail.url)).catchAll(ZIO.dieMessage(_)) 81 | req = basicRequest.get(uri).response(asPath(StaticConfig.coversPath.resolve(s"${podcast.id}.jpg"))) 82 | downloadedThumbnail <- Sttp.send(req) 83 | _ <- ZIO.whenCase(downloadedThumbnail.body) { case Right(_) => 84 | PodcastDao.updateImage(podcast.id, Some(s"${podcast.id}.jpg")) 85 | } 86 | } yield () 87 | } 88 | } 89 | } yield podcasts 90 | 91 | case SourceType.Directory => 92 | val (errors, results) = sources.map { source => 93 | Podcast.fromDirectory(Paths.get(source)) 94 | }.partitionMap(identity) 95 | 96 | if (errors.nonEmpty) { 97 | ZIO.fail(ApiError.BadRequest(errors.mkString("\n"))) 98 | } else { 99 | PodcastDao.createAll(results) 100 | } 101 | } 102 | 103 | def checkForUpdatesAll( 104 | downloadQueue: Queue[CreateEpisodeRequest] 105 | ): RIO[Sttp & DataSource & Config, Unit] = 106 | for { 107 | podcasts <- PodcastDao.list 108 | externalSources <- EpisodeDao.listExternalSource.map(_.toSet) 109 | _ <- ZIO.foreachDiscard(podcasts) { podcast => 110 | enqueueDownload(downloadQueue)(podcast, externalSources) 111 | } 112 | } yield () 113 | 114 | def checkForUpdates( 115 | downloadQueue: Queue[CreateEpisodeRequest] 116 | )(id: PodcastId): RIO[Sttp & DataSource & Config, Unit] = 117 | for { 118 | podcast <- PodcastDao.get(id).someOrFail(ApiError.NotFound(s"Podcast $id does not exist.")) 119 | externalSources <- EpisodeDao.listExternalSource.map(_.toSet) 120 | _ <- enqueueDownload(downloadQueue)(podcast, externalSources) 121 | } yield () 122 | 123 | private def enqueueDownload(downloadQueue: Queue[CreateEpisodeRequest])( 124 | podcast: Podcast.Model, 125 | excludeExternalSources: Set[String] 126 | ): RIO[Sttp & Config, Unit] = 127 | podcast.sourceType match { 128 | case SourceType.YouTube => enqueueDownloadYouTube(downloadQueue)(podcast, excludeExternalSources) 129 | case SourceType.Directory => enqueueDownloadFile(downloadQueue)(podcast, excludeExternalSources) 130 | } 131 | 132 | private def enqueueDownloadFile( 133 | downloadQueue: Queue[CreateEpisodeRequest] 134 | )(podcast: Podcast.Model, excludeExternalSources: Set[String]): RIO[Any, Unit] = { 135 | import podpodge.util.FileExtensions.* 136 | 137 | val excludeExternalPaths = excludeExternalSources.map(s => Paths.get(s).normalize().toFile) 138 | 139 | ZStream 140 | .fromJavaStream(Files.walk(Paths.get(podcast.externalSource))) 141 | .map(_.normalize().toFile) 142 | .filterNot(item => excludeExternalPaths.contains(item)) 143 | .filter { s => 144 | s.extension match { 145 | case Some(ext) if Set("mp3", "ogg", "m4a").contains(ext.toLowerCase) => true 146 | case _ => false 147 | } 148 | } 149 | .foreach { item => 150 | ZIO.logTrace(s"Putting '$item' in download queue") *> 151 | downloadQueue.offer(CreateEpisodeRequest.File(podcast.id, item)) 152 | } 153 | .tap(_ => ZIO.logInfo(s"Done checking for new episode files for Podcast ${podcast.id}")) 154 | } 155 | 156 | private def enqueueDownloadYouTube( 157 | downloadQueue: Queue[CreateEpisodeRequest] 158 | )( 159 | podcast: Podcast.Model, 160 | excludeExternalSources: Set[String] 161 | ): RIO[Sttp & Config, Unit] = for { 162 | youTubeApiKey <- config.youTubeApiKey 163 | result <- // TODO: Update lastCheckDate here. Will definitely need it for the cron schedule feature. 164 | YouTubeClient 165 | .listPlaylistItems(podcast.externalSource, youTubeApiKey) 166 | .filterNot(item => excludeExternalSources.contains(item.snippet.resourceId.videoId)) 167 | .foreach { item => 168 | ZIO.logTrace(s"Putting '${item.snippet.title}' (${item.snippet.resourceId.videoId}) in download queue") *> 169 | downloadQueue.offer(CreateEpisodeRequest.YouTube(podcast.id, item)) 170 | } 171 | .tap(_ => ZIO.logInfo(s"Done checking for new YouTube episodes for Podcast ${podcast.id}")) 172 | } yield result 173 | 174 | } 175 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/Configuration.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db 2 | 3 | import io.circe.{Decoder, Encoder} 4 | import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} 5 | import podpodge.types.* 6 | 7 | // All fields are optional because there are other config sources to fall back to (like env vars). 8 | final case class Configuration[ID]( 9 | id: ID, 10 | youTubeApiKey: Option[YouTubeApiKey], 11 | serverHost: Option[ServerHost], 12 | serverPort: Option[ServerPort], 13 | serverScheme: Option[ServerScheme], 14 | downloaderPath: Option[DownloaderPath] 15 | ) 16 | 17 | object Configuration { 18 | type Model = Configuration[ConfigurationId] 19 | type Insert = Configuration[Option[ConfigurationId]] 20 | 21 | implicit val encoder: Encoder[Configuration.Model] = deriveEncoder[Configuration.Model] 22 | implicit val decoder: Decoder[Configuration.Model] = deriveDecoder[Configuration.Model] 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/DbMigration.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db 2 | 3 | import org.flywaydb.core.api.output.MigrateResult 4 | import org.flywaydb.core.Flyway 5 | import zio.Task 6 | import zio.ZIO 7 | 8 | object DbMigration { 9 | 10 | def migrate: Task[MigrateResult] = 11 | ZIO.attempt { 12 | val flyway = Flyway 13 | .configure() 14 | .dataSource("jdbc:sqlite:data/podpodge.db", "", "") 15 | .locations("filesystem:migration") 16 | .load() 17 | 18 | flyway.migrate() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/Episode.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db 2 | 3 | import podpodge.types.{EpisodeId, PodcastId, *} 4 | import podpodge.StaticConfig 5 | import sttp.client3.* 6 | import sttp.model.Uri 7 | 8 | import java.io.File 9 | import java.nio.file.Path 10 | import java.time.{Duration, OffsetDateTime} 11 | import scala.util.Try 12 | 13 | final case class Episode[ID]( 14 | id: ID, 15 | podcastId: PodcastId, 16 | guid: String, 17 | externalSource: String, 18 | title: String, 19 | publishDate: OffsetDateTime, 20 | image: Option[String], 21 | mediaFile: Option[String], 22 | duration: Duration 23 | ) { 24 | 25 | def imagePath: Option[Path] = 26 | image.flatMap(name => Try(StaticConfig.thumbnailsPath.resolve(podcastId.unwrap.toString).resolve(name)).toOption) 27 | 28 | def linkUrl(sourceType: SourceType): Uri = sourceType match { 29 | case SourceType.YouTube => uri"https://www.youtube.com/playlist?list=$externalSource" 30 | case SourceType.Directory => Uri(new File(externalSource).toURI) 31 | } 32 | } 33 | 34 | object Episode { 35 | type Model = Episode[EpisodeId] 36 | type Insert = Episode[Option[EpisodeId]] 37 | } 38 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/Podcast.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db 2 | 3 | import io.circe.{Decoder, Encoder} 4 | import io.circe.generic.semiauto.* 5 | import podpodge.types.{PodcastId, SourceType} 6 | import podpodge.youtube.Playlist 7 | import podpodge.StaticConfig 8 | import sttp.client3.* 9 | import sttp.model.Uri 10 | 11 | import java.io.File 12 | import java.nio.file.Path 13 | import java.time.{Instant, OffsetDateTime, ZoneOffset} 14 | import scala.util.Try 15 | 16 | final case class Podcast[ID]( 17 | id: ID, 18 | externalSource: String, 19 | sourceType: SourceType, 20 | title: String, 21 | description: String, 22 | category: String, 23 | generator: String, 24 | lastBuildDate: OffsetDateTime, 25 | publishDate: OffsetDateTime, 26 | author: String, 27 | subtitle: String, 28 | summary: String, 29 | image: Option[String], 30 | lastCheckDate: Option[OffsetDateTime] 31 | ) { 32 | 33 | def imagePath: Option[Path] = 34 | image.flatMap(name => Try(StaticConfig.coversPath.resolve(name)).toOption) 35 | 36 | def linkUrl: Uri = sourceType match { 37 | case SourceType.YouTube => uri"https://www.youtube.com/playlist?list=$externalSource" 38 | case SourceType.Directory => Uri(new File(externalSource).toURI) 39 | } 40 | } 41 | 42 | object Podcast { 43 | type Model = Podcast[PodcastId] 44 | type Insert = Podcast[Unit] 45 | 46 | implicit val encoder: Encoder[Podcast.Model] = deriveEncoder[Podcast.Model] 47 | implicit val decoder: Decoder[Podcast.Model] = deriveDecoder[Podcast.Model] 48 | 49 | def fromPlaylist(playlist: Playlist): Podcast.Insert = 50 | Podcast( 51 | PodcastId.empty, 52 | playlist.id, 53 | SourceType.YouTube, 54 | playlist.snippet.title, 55 | playlist.snippet.description, 56 | "TV & Film", 57 | "Generated by Podpodge", 58 | OffsetDateTime.now, 59 | playlist.snippet.publishedAt, 60 | playlist.snippet.channelTitle, 61 | playlist.snippet.title, 62 | playlist.snippet.description, 63 | None, 64 | None 65 | ) 66 | 67 | def fromDirectory(path: Path): Either[String, Podcast.Insert] = { 68 | val directory = path.toFile 69 | 70 | if (!directory.exists()) 71 | Left(s"${directory.getAbsolutePath} does not exist") 72 | else if (directory.isFile) 73 | Left(s"${directory.getAbsolutePath} is not a directory") 74 | else { 75 | val description = s"Files of directory ${directory.getName}" 76 | 77 | Right( 78 | Podcast( 79 | PodcastId.empty, 80 | directory.toString, 81 | SourceType.Directory, 82 | directory.getName, 83 | description, 84 | "TV & Film", 85 | "Generated by Podpodge", 86 | OffsetDateTime.now, 87 | Instant.ofEpochMilli(directory.lastModified()).atOffset(ZoneOffset.UTC), 88 | System.getProperty("user.name"), 89 | directory.getAbsolutePath, 90 | description, 91 | None, 92 | None 93 | ) 94 | ) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/dao/ConfigurationDao.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db.dao 2 | 3 | import podpodge.db.patch.PatchConfiguration 4 | import podpodge.db.Configuration 5 | import podpodge.types.ConfigurationId 6 | import zio.ZIO 7 | 8 | import java.sql.SQLException 9 | import javax.sql.DataSource 10 | 11 | object ConfigurationDao extends SqlDao { 12 | import ctx.* 13 | 14 | def getPrimary: ZIO[DataSource, SQLException, Configuration.Model] = 15 | for { 16 | configOpt <- ctx.run { 17 | quote(query[Configuration.Model].take(1)) 18 | }.map(_.headOption) 19 | config <- configOpt match { 20 | case Some(config) => ZIO.succeed(config) 21 | case None => 22 | create( 23 | Configuration( 24 | id = ConfigurationId.empty, 25 | youTubeApiKey = None, 26 | serverHost = None, 27 | serverPort = None, 28 | serverScheme = None, 29 | downloaderPath = None 30 | ) 31 | ) 32 | } 33 | } yield config 34 | 35 | def get(id: ConfigurationId): ZIO[DataSource, SQLException, Option[Configuration.Model]] = 36 | ctx.run { 37 | quote(query[Configuration.Model].filter(_.id == lift(id)).take(1)) 38 | }.map(_.headOption) 39 | 40 | def create(config: Configuration.Insert): ZIO[DataSource, SQLException, Configuration.Model] = 41 | ctx.run { 42 | quote( 43 | query[Configuration[ConfigurationId]] 44 | .insertValue(lift(config.copy(id = ConfigurationId(0)))) 45 | .returningGenerated(_.id) 46 | ) 47 | }.map(id => config.copy(id = id)) 48 | 49 | def update(model: Configuration.Model): ZIO[DataSource, SQLException, Long] = 50 | ctx.run { 51 | quote(query[Configuration.Model].filter(_.id == lift(model.id)).updateValue(lift(model))) 52 | } 53 | 54 | def patch( 55 | id: ConfigurationId, 56 | patch: PatchConfiguration 57 | ): ZIO[DataSource, Exception, Configuration.Model] = 58 | // TODO: Currently this is a hacky get + update. Fix this to programmatically generate the update statement instead. 59 | // I don't know how to do this with Quill though. I might need to use something else for it? 60 | for { 61 | originalConfig <- 62 | get(id).someOrFail(new Exception(s"Cannot patch because ConfigurationId=$id does not exist in DB")) 63 | patchedConfig = Configuration( 64 | id = id, 65 | youTubeApiKey = patch.youTubeApiKey.specify(originalConfig.youTubeApiKey), 66 | serverHost = patch.serverHost.specify(originalConfig.serverHost), 67 | serverPort = patch.serverPort.specify(originalConfig.serverPort), 68 | serverScheme = patch.serverScheme.specify(originalConfig.serverScheme), 69 | downloaderPath = patch.downloaderPath.specify(originalConfig.downloaderPath) 70 | ) 71 | _ <- update(patchedConfig) 72 | } yield patchedConfig 73 | } 74 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/dao/EpisodeDao.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db.dao 2 | 3 | import podpodge.db.Episode 4 | import podpodge.db.Episode.Model 5 | import podpodge.types.{EpisodeId, PodcastId} 6 | import zio.ZIO 7 | 8 | import java.sql.SQLException 9 | import javax.sql.DataSource 10 | 11 | object EpisodeDao extends SqlDao { 12 | import ctx.* 13 | 14 | def get(id: EpisodeId): ZIO[DataSource, SQLException, Option[Model]] = 15 | ctx.run { 16 | quote(query[Episode.Model].filter(_.id == lift(id)).take(1)) 17 | }.map(_.headOption) 18 | 19 | def list: ZIO[DataSource, SQLException, List[Model]] = 20 | ctx.run { 21 | quote(query[Episode.Model]) 22 | } 23 | 24 | def listExternalSource: ZIO[DataSource, SQLException, List[String]] = 25 | ctx.run { 26 | quote(query[Episode.Model].map(_.externalSource)) 27 | } 28 | 29 | def listByPodcast(id: PodcastId): ZIO[DataSource, SQLException, List[Model]] = 30 | ctx.run { 31 | quote(query[Episode.Model].filter(_.podcastId == lift(id))) 32 | } 33 | 34 | def create(episode: Episode.Insert): ZIO[DataSource, SQLException, Episode[EpisodeId]] = 35 | ctx.run { 36 | quote( 37 | query[Episode[EpisodeId]].insertValue(lift(episode.copy(id = EpisodeId(0)))).returningGenerated(_.id) 38 | ) 39 | }.map(id => episode.copy(id = id)) 40 | 41 | def createAll( 42 | episodes: List[Episode.Insert] 43 | ): ZIO[DataSource, SQLException, List[Episode[EpisodeId]]] = 44 | ctx.run { 45 | liftQuery(episodes.map(_.copy(id = EpisodeId(0)))).foreach(e => 46 | query[Episode[EpisodeId]].insertValue(e).returningGenerated(_.id) 47 | ) 48 | }.map(ids => episodes.zip(ids).map { case (p, i) => p.copy(id = i) }) 49 | 50 | def updateImage(id: EpisodeId, s: Option[String]): ZIO[DataSource, SQLException, Long] = 51 | ctx.run { 52 | query[Episode.Model].filter(_.id == lift(id)).update(_.image -> lift(s)) 53 | } 54 | 55 | def updateMediaFile(id: EpisodeId, s: Option[String]): ZIO[DataSource, SQLException, Long] = 56 | ctx.run { 57 | query[Episode.Model].filter(_.id == lift(id)).update(_.mediaFile -> lift(s)) 58 | } 59 | 60 | def delete(id: EpisodeId): ZIO[DataSource, SQLException, Long] = 61 | ctx.run { 62 | quote(query[Episode.Model].filter(_.id == lift(id)).delete) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/dao/PodcastDao.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db.dao 2 | 3 | import podpodge.db.Podcast 4 | import podpodge.db.Podcast.Model 5 | import podpodge.types.PodcastId 6 | import zio.ZIO 7 | 8 | import java.sql.SQLException 9 | import javax.sql.DataSource 10 | 11 | object PodcastDao extends SqlDao { 12 | import ctx.* 13 | 14 | def get(id: PodcastId): ZIO[DataSource, SQLException, Option[Podcast.Model]] = 15 | ctx.run { 16 | quote(query[Podcast.Model].filter(_.id == lift(id)).take(1)) 17 | }.map(_.headOption) 18 | 19 | def list: ZIO[DataSource, SQLException, List[Model]] = 20 | ctx.run { 21 | quote(query[Podcast.Model]) 22 | } 23 | 24 | def create(podcast: Podcast.Insert): ZIO[DataSource, SQLException, Podcast[PodcastId]] = 25 | ctx.run { 26 | quote(query[Podcast[PodcastId]].insertValue(lift(podcast.copy(id = PodcastId(0)))).returningGenerated(_.id)) 27 | }.map(id => podcast.copy(id = id)) // TODO: Abstract this out 28 | 29 | def createAll( 30 | podcasts: List[Podcast.Insert] 31 | ): ZIO[DataSource, SQLException, List[Podcast[PodcastId]]] = 32 | ctx.run { 33 | liftQuery(podcasts.map(_.copy(id = PodcastId(0)))).foreach(e => 34 | query[Podcast[PodcastId]].insertValue(e).returningGenerated(_.id) 35 | ) 36 | }.map(ids => podcasts.zip(ids).map { case (p, i) => p.copy(id = i) }) 37 | 38 | def updateImage(id: PodcastId, s: Option[String]): ZIO[DataSource, SQLException, Long] = 39 | ctx.run { 40 | query[Podcast.Model].filter(_.id == lift(id)).update(_.image -> lift(s)) 41 | } 42 | 43 | def delete(id: PodcastId): ZIO[DataSource, SQLException, Long] = 44 | ctx.run { 45 | quote(query[Podcast.Model].filter(_.id == lift(id)).delete) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/dao/SqlDao.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db.dao 2 | 3 | import io.getquill.{MappedEncoding, SnakeCase, SqliteZioJdbcContext} 4 | import podpodge.types.* 5 | import zio.prelude.Equivalence 6 | 7 | import java.time.{Duration, OffsetDateTime} 8 | import java.time.format.DateTimeFormatter 9 | 10 | trait SqlDao extends MappedEncodings { 11 | val ctx: SqliteZioJdbcContext[SnakeCase.type] = SqlDao.ctx 12 | } 13 | 14 | object SqlDao { 15 | 16 | lazy val ctx: SqliteZioJdbcContext[SnakeCase.type] = new SqliteZioJdbcContext(SnakeCase) { 17 | 18 | implicit override val offsetDateTimeDecoder: Decoder[OffsetDateTime] = { 19 | val mapped = MappedEncoding[String, OffsetDateTime] { s => 20 | OffsetDateTime.parse(s, DateTimeFormatter.ISO_OFFSET_DATE_TIME) 21 | } 22 | 23 | JdbcDecoder(mappedBaseDecoder(mapped, stringDecoder.decoder)) 24 | } 25 | 26 | implicit override val offsetDateTimeEncoder: Encoder[OffsetDateTime] = { 27 | val mapped = MappedEncoding[OffsetDateTime, String] { dt => 28 | dt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) 29 | } 30 | 31 | JdbcEncoder(stringEncoder.sqlType, mappedBaseEncoder(mapped, stringEncoder.encoder)) 32 | } 33 | } 34 | } 35 | 36 | trait MappedEncodings { 37 | 38 | implicit val durationEncoder: MappedEncoding[Duration, String] = 39 | MappedEncoding[Duration, String](_.toString) 40 | 41 | implicit val durationDecoder: MappedEncoding[String, Duration] = 42 | MappedEncoding[String, Duration](Duration.parse) 43 | 44 | implicit def newtypeEncoder[A, T <: RichNewtype[A]#Type](implicit equiv: Equivalence[A, T]): MappedEncoding[T, A] = 45 | MappedEncoding[T, A](RichNewtype.unwrap(_)) 46 | 47 | implicit def newtypeDecoder[A, T <: RichNewtype[A]#Type](implicit equiv: Equivalence[A, T]): MappedEncoding[A, T] = 48 | MappedEncoding[A, T](RichNewtype.wrap(_)) 49 | 50 | implicit val sourceTypeEncoder: MappedEncoding[SourceType, String] = 51 | MappedEncoding[SourceType, String](_.entryName) 52 | 53 | implicit val sourceTypeDecoder: MappedEncoding[String, SourceType] = 54 | MappedEncoding[String, SourceType](SourceType.withName) 55 | } 56 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/db/patch/PatchConfiguration.scala: -------------------------------------------------------------------------------- 1 | package podpodge.db.patch 2 | 3 | import io.circe.{Decoder, Encoder} 4 | import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} 5 | import podpodge.types.{DownloaderPath, ServerHost, ServerPort, ServerScheme, Tristate, YouTubeApiKey} 6 | 7 | final case class PatchConfiguration( 8 | youTubeApiKey: Tristate[YouTubeApiKey] = Tristate.None, 9 | serverHost: Tristate[ServerHost] = Tristate.None, 10 | serverPort: Tristate[ServerPort] = Tristate.None, 11 | serverScheme: Tristate[ServerScheme] = Tristate.None, 12 | downloaderPath: Tristate[DownloaderPath] = Tristate.None 13 | ) 14 | 15 | object PatchConfiguration { 16 | implicit val encoder: Encoder[PatchConfiguration] = deriveEncoder[PatchConfiguration] 17 | implicit val decoder: Decoder[PatchConfiguration] = deriveDecoder[PatchConfiguration] 18 | } 19 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/http/ApiError.scala: -------------------------------------------------------------------------------- 1 | package podpodge.http 2 | 3 | import io.circe.{Decoder, Encoder} 4 | import io.circe.generic.semiauto.* 5 | 6 | sealed trait ApiError extends Exception 7 | 8 | object ApiError { 9 | final case class NotFound(message: String) extends ApiError 10 | 11 | object NotFound { 12 | implicit val decoder: Decoder[NotFound] = deriveDecoder[NotFound] 13 | implicit val encoder: Encoder[NotFound] = deriveEncoder[NotFound] 14 | } 15 | 16 | final case class BadRequest(message: String) extends ApiError 17 | 18 | object BadRequest { 19 | implicit val decoder: Decoder[BadRequest] = deriveDecoder[BadRequest] 20 | implicit val encoder: Encoder[BadRequest] = deriveEncoder[BadRequest] 21 | } 22 | 23 | final case class InternalError(message: String) extends ApiError 24 | 25 | object InternalError { 26 | implicit val decoder: Decoder[InternalError] = deriveDecoder[InternalError] 27 | implicit val encoder: Encoder[InternalError] = deriveEncoder[InternalError] 28 | } 29 | 30 | implicit val decoder: Decoder[ApiError] = deriveDecoder[ApiError] 31 | implicit val encoder: Encoder[ApiError] = deriveEncoder[ApiError] 32 | } 33 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/http/HttpError.scala: -------------------------------------------------------------------------------- 1 | package podpodge.http 2 | 3 | import org.apache.pekko.http.scaladsl.marshalling.ToResponseMarshaller 4 | 5 | final case class HttpError[A: ToResponseMarshaller](result: A) extends Exception() 6 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/http/PekkoHttp.scala: -------------------------------------------------------------------------------- 1 | package podpodge.http 2 | 3 | import org.apache.pekko.http.scaladsl.marshalling.{ToResponseMarshallable, ToResponseMarshaller} 4 | import org.apache.pekko.http.scaladsl.model.StatusCodes 5 | import org.apache.pekko.http.scaladsl.server.Directives.* 6 | import org.apache.pekko.http.scaladsl.server.Route 7 | import podpodge.Env 8 | import zio.* 9 | 10 | import scala.util.{Failure, Success} 11 | 12 | object PekkoHttp { 13 | 14 | implicit def toZioRoute[A: ToResponseMarshaller](zio: ZIO[Env, Throwable, A])(implicit runtime: Runtime[Env]): Route = 15 | zioRoute(zio) 16 | 17 | def zioRoute[A: ToResponseMarshaller](zio: ZIO[Env, Throwable, A])(implicit runtime: Runtime[Env]): Route = { 18 | 19 | val asFuture = Unsafe.unsafe { implicit u => 20 | runtime.unsafe.runToFuture { 21 | zio.tapErrorCause(c => ZIO.logErrorCause("Unhandled internal server error", c)).either 22 | } 23 | } 24 | 25 | onComplete(asFuture) { 26 | case Success(Right(value)) => complete(ToResponseMarshallable(value)) 27 | case Success(Left(ApiError.NotFound(_))) => complete(StatusCodes.NotFound) 28 | case Success(Left(_)) | Failure(_) => complete(StatusCodes.InternalServerError) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/http/Sttp.scala: -------------------------------------------------------------------------------- 1 | package podpodge.http 2 | 3 | import sttp.capabilities.zio.ZioStreams 4 | import sttp.client3.{Identity, RequestT, Response, SttpBackend} 5 | import sttp.client3.httpclient.zio.HttpClientZioBackend 6 | import zio.{Task, ZIO, ZLayer} 7 | 8 | trait Sttp { 9 | def send[T](request: RequestT[Identity, T, ZioStreams]): Task[Response[T]] 10 | } 11 | 12 | object Sttp { 13 | 14 | def send[T](request: RequestT[Identity, T, ZioStreams]): ZIO[Sttp, Throwable, Response[T]] = 15 | ZIO.serviceWithZIO[Sttp](_.send(request)) 16 | } 17 | 18 | final case class SttpLive(backend: SttpBackend[Task, ZioStreams]) extends Sttp { 19 | 20 | def send[T](request: RequestT[Identity, T, ZioStreams]): Task[Response[T]] = 21 | request.send(backend) 22 | } 23 | 24 | object SttpLive { 25 | 26 | def make: ZLayer[Any, Throwable, Sttp] = 27 | ZLayer { 28 | for { 29 | backend <- HttpClientZioBackend() 30 | } yield SttpLive(backend) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/package.scala: -------------------------------------------------------------------------------- 1 | import podpodge.config.Config 2 | import podpodge.http.Sttp 3 | 4 | import javax.sql.DataSource 5 | 6 | package object podpodge { 7 | type Env = Sttp & DataSource & Config 8 | } 9 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/rss/Episode.scala: -------------------------------------------------------------------------------- 1 | package podpodge.rss 2 | 3 | import sttp.model.Uri 4 | 5 | import java.time.{Duration, OffsetDateTime} 6 | 7 | final case class Episode( 8 | downloadUrl: Uri, 9 | guid: String, 10 | linkUrl: Uri, 11 | title: String, 12 | publishDate: OffsetDateTime, 13 | duration: Duration, 14 | imageUrl: Uri 15 | ) 16 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/rss/Podcast.scala: -------------------------------------------------------------------------------- 1 | package podpodge.rss 2 | 3 | import podpodge.config.PodpodgeConfig 4 | import podpodge.db 5 | import sttp.model.Uri 6 | 7 | import java.time.OffsetDateTime 8 | 9 | final case class Podcast( 10 | title: String, 11 | linkUrl: Uri, 12 | description: String, 13 | category: String, 14 | generator: String, 15 | lastBuildDate: OffsetDateTime, 16 | publishDate: OffsetDateTime, 17 | author: String, 18 | subtitle: String, 19 | summary: String, 20 | imageUrl: Uri, 21 | items: List[Episode] 22 | ) 23 | 24 | object Podcast { 25 | 26 | def fromDB(podcast: db.Podcast.Model, episodes: List[db.Episode.Model], config: PodpodgeConfig): Podcast = 27 | Podcast( 28 | podcast.title, 29 | podcast.linkUrl, 30 | podcast.description, 31 | podcast.category, 32 | podcast.generator, 33 | podcast.lastBuildDate, 34 | podcast.publishDate, 35 | podcast.author, 36 | podcast.subtitle, 37 | podcast.summary, 38 | config.baseUri.withPath("cover", podcast.id.unwrap.toString), 39 | episodes.map { episode => 40 | Episode( 41 | config.baseUri.withPath("episode", episode.id.unwrap.toString, "file"), 42 | episode.externalSource, 43 | episode.linkUrl(podcast.sourceType), 44 | episode.title, 45 | episode.publishDate, 46 | episode.duration, 47 | config.baseUri.withPath("thumbnail", episode.id.unwrap.toString) 48 | ) 49 | } 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/server/PodpodgeServer.scala: -------------------------------------------------------------------------------- 1 | package podpodge.server 2 | 3 | import org.apache.pekko.actor.typed.scaladsl.Behaviors 4 | import org.apache.pekko.actor.typed.ActorSystem 5 | import org.apache.pekko.http.scaladsl.Http 6 | import podpodge.* 7 | import podpodge.types.EpisodeId 8 | import zio.* 9 | 10 | import java.io.File 11 | 12 | object PodpodgeServer { 13 | 14 | def make: ZIO[Scope & Env, Throwable, Http.ServerBinding] = { 15 | implicit val system: ActorSystem[Nothing] = ActorSystem(Behaviors.empty, "podpodge-system") 16 | 17 | for { 18 | config <- config.get 19 | downloadQueue <- Queue.unbounded[CreateEpisodeRequest] 20 | _ <- DownloadWorker.make(downloadQueue).forkDaemon 21 | episodesDownloading <- Ref.Synchronized.make(Map.empty[EpisodeId, Promise[Throwable, File]]) 22 | runtime <- ZIO.runtime[Env] 23 | server <- ZIO.acquireRelease( 24 | ZIO.fromFuture { _ => 25 | Http() 26 | .newServerAt(config.serverHost.unwrap, config.serverPort.unwrap) 27 | .bind(Routes.make(downloadQueue, episodesDownloading)(runtime)) 28 | } <* ZIO.logInfo(s"Starting server at ${config.baseUri}") 29 | )(server => 30 | (for { 31 | _ <- ZIO.logInfo("Shutting down server") 32 | _ <- ZIO.fromFuture(_ => server.unbind()) 33 | _ <- ZIO.attempt(system.terminate()) 34 | _ <- ZIO.fromFuture(_ => system.whenTerminated) 35 | } yield ()).orDie 36 | ) 37 | } yield server 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/server/RawRoutes.scala: -------------------------------------------------------------------------------- 1 | package podpodge.server 2 | 3 | import org.apache.pekko.http.scaladsl.model.* 4 | import org.apache.pekko.http.scaladsl.server.{PathMatcher1, Route} 5 | import org.apache.pekko.http.scaladsl.server.Directives.* 6 | import podpodge.controllers.EpisodeController 7 | import podpodge.http.PekkoHttp.* 8 | import podpodge.types.{EpisodeId, PodcastId} 9 | import podpodge.Env 10 | import zio.{Promise, Ref, Runtime} 11 | 12 | import java.io.File 13 | import scala.concurrent.duration.* 14 | 15 | // Routes that are using plain pekko-http rather than through tapir's interface. 16 | object RawRoutes { 17 | 18 | val PodcastIdPart: PathMatcher1[PodcastId] = LongNumber.map(PodcastId(_)) 19 | val EpisodeIdPart: PathMatcher1[EpisodeId] = LongNumber.map(EpisodeId(_)) 20 | 21 | def all( 22 | episodesDownloading: Ref.Synchronized[Map[EpisodeId, Promise[Throwable, File]]] 23 | )(implicit runtime: Runtime[Env]): Route = 24 | pathSingleSlash { 25 | redirect("/docs", StatusCodes.TemporaryRedirect) 26 | } ~ 27 | path("episode" / EpisodeIdPart / "file") { id => 28 | // TODO: Make this configurable. 29 | // The timeout is set to ridiculously long value because downloading a YouTube video can take a long time, and this 30 | // route can also initiate a download if the media file doesn't already exist. 31 | withRequestTimeout(1.hour) { 32 | withRangeSupport { 33 | get { 34 | EpisodeController.getEpisodeFileOnDemand(episodesDownloading)(id) 35 | } 36 | } 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/server/Routes.scala: -------------------------------------------------------------------------------- 1 | package podpodge.server 2 | 3 | import org.apache.pekko.http.scaladsl.server.Route 4 | import podpodge.{CreateEpisodeRequest, Env} 5 | import podpodge.controllers.{ConfigurationController, EpisodeController, PodcastController} 6 | import podpodge.db.{Configuration, Podcast} 7 | import podpodge.db.dao.ConfigurationDao 8 | import podpodge.db.patch.PatchConfiguration 9 | import podpodge.db.Podcast.Model 10 | import podpodge.http.ApiError 11 | import podpodge.types.* 12 | import sttp.capabilities.pekko.PekkoStreams 13 | import sttp.model.StatusCode 14 | import sttp.tapir.* 15 | import sttp.tapir.codec.enumeratum.TapirCodecEnumeratum 16 | import sttp.tapir.generic.auto.* 17 | import sttp.tapir.json.circe.* 18 | import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter 19 | import sttp.tapir.swagger.bundle.SwaggerInterpreter 20 | import zio.{Promise, Queue, Ref, Runtime} 21 | 22 | import java.io.File 23 | import scala.concurrent.Future 24 | import scala.xml.Elem 25 | 26 | object Routes extends TapirSupport with TapirCodecEnumeratum { 27 | 28 | val listPodcastsEndpoint: Endpoint[Unit, Unit, ApiError, List[Model], Any] = 29 | endpoint 30 | .in("podcasts") 31 | .errorOut(apiError) 32 | .out(jsonBody[List[Podcast.Model]]) 33 | .description("List all the podcasts currently registered.") 34 | 35 | val getPodcastEndpoint = 36 | endpoint 37 | .in("podcast" / path[PodcastId]("podcastId")) 38 | .errorOut(apiError) 39 | .out(jsonBody[Podcast.Model]) 40 | .description("Get a single podcast by its ID.") 41 | 42 | val rssEndpoint = 43 | endpoint.get 44 | .in("podcast" / path[PodcastId]("podcastId") / "rss") 45 | .errorOut(apiError) 46 | .out(xmlBody[Elem]) 47 | .description("Get the RSS feed for the specified podcast.") 48 | 49 | val checkForUpdatesAllEndpoint = 50 | endpoint.post 51 | .in("podcasts" / "check") 52 | .errorOut(apiError) 53 | .out(statusCode(StatusCode.Ok)) 54 | .description("Checks for new episodes for all registered podcasts.") 55 | 56 | val checkForUpdatesEndpoint = 57 | endpoint.post 58 | .in("podcast" / path[PodcastId]("podcastId") / "check") 59 | .errorOut(apiError) 60 | .out(statusCode(StatusCode.Ok)) 61 | .description("Checks for new episodes for the specified podcast.") 62 | 63 | val createPodcastEndpoint = 64 | endpoint.post 65 | .in("podcast" / path[SourceType]("sourceType")) 66 | .in(query[List[String]]("playlistId")) 67 | .errorOut(apiError) 68 | .out(jsonBody[List[Podcast.Model]]) 69 | .description("Creates Podcast feeds for the specified YouTube playlist IDs.") 70 | 71 | val getConfigEndpoint = 72 | endpoint.get 73 | .in("configuration") 74 | .errorOut(apiError) 75 | .out(jsonBody[Configuration.Model]) 76 | .description("Get Podpodge configuration") 77 | 78 | val updateConfigEndpoint = 79 | endpoint.patch 80 | .in("configuration") 81 | .in( 82 | jsonBody[PatchConfiguration].example( 83 | PatchConfiguration( 84 | Tristate.Some(YouTubeApiKey("YOUR_API_KEY")), 85 | Tristate.Some(ServerHost("localhost")), 86 | Tristate.Some(ServerPort.makeUnsafe(80)), 87 | Tristate.Some(ServerScheme("http")), 88 | Tristate.Some(DownloaderPath("youtube-dl")) 89 | ) 90 | ) 91 | ) 92 | .errorOut(apiError) 93 | .out(jsonBody[Configuration.Model]) 94 | .description( 95 | "Updates Podpodge configuration. Pass `null` to clear out a field. If you want a field to remain unchanged, simply leave the field out from the JSON body completely." 96 | ) 97 | 98 | val coverEndpoint = 99 | endpoint.get 100 | .in("cover" / path[PodcastId]("podcastId")) 101 | .errorOut(apiError) 102 | .out(streamBinaryBody(PekkoStreams)(imageJpegCodec)) 103 | 104 | val thumbnailEndpoint = 105 | endpoint.get 106 | .in("thumbnail" / path[EpisodeId]("episodeId")) 107 | .errorOut(apiError) 108 | .out(streamBinaryBody(PekkoStreams)(imageJpegCodec)) 109 | 110 | def make( 111 | downloadQueue: Queue[CreateEpisodeRequest], 112 | episodesDownloading: Ref.Synchronized[Map[EpisodeId, Promise[Throwable, File]]] 113 | )(implicit runtime: Runtime[Env]): Route = { 114 | import org.apache.pekko.http.scaladsl.server.Directives.* 115 | 116 | implicit val interpreter: PekkoHttpServerInterpreter = 117 | PekkoHttpServerInterpreter()(scala.concurrent.ExecutionContext.Implicits.global) 118 | 119 | listPodcastsEndpoint.toZRoute(_ => PodcastController.listPodcasts) ~ 120 | getPodcastEndpoint.toZRoute(PodcastController.getPodcast) ~ 121 | rssEndpoint.toZRoute(PodcastController.getPodcastRss) ~ 122 | checkForUpdatesAllEndpoint.toZRoute(_ => PodcastController.checkForUpdatesAll(downloadQueue)) ~ 123 | checkForUpdatesEndpoint.toZRoute(PodcastController.checkForUpdates(downloadQueue)) ~ 124 | createPodcastEndpoint.toZRoute((PodcastController.create _).tupled(_)) ~ 125 | getConfigEndpoint.toZRoute(_ => ConfigurationController.getPrimary) ~ 126 | updateConfigEndpoint.toZRoute({ patch => 127 | for { 128 | defaultConfiguration <- ConfigurationDao.getPrimary 129 | result <- ConfigurationController.patch(defaultConfiguration.id, patch) 130 | } yield result 131 | }) ~ 132 | coverEndpoint.toZRoute(PodcastController.getPodcastCover) ~ 133 | thumbnailEndpoint.toZRoute(EpisodeController.getThumbnail) ~ 134 | RawRoutes.all(episodesDownloading) ~ 135 | swaggerRoute 136 | } 137 | 138 | def swaggerRoute(implicit interpreter: PekkoHttpServerInterpreter) = 139 | interpreter.toRoute( 140 | SwaggerInterpreter().fromEndpoints[Future]( 141 | List( 142 | listPodcastsEndpoint, 143 | getPodcastEndpoint, 144 | rssEndpoint, 145 | checkForUpdatesAllEndpoint, 146 | checkForUpdatesEndpoint, 147 | createPodcastEndpoint, 148 | getConfigEndpoint, 149 | updateConfigEndpoint 150 | ), 151 | "Podpodge Docs", 152 | "0.2.0" 153 | ) 154 | ) 155 | 156 | } 157 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/server/TapirSupport.scala: -------------------------------------------------------------------------------- 1 | package podpodge.server 2 | 3 | import org.apache.pekko.http.scaladsl.server.Route 4 | import podpodge.http.ApiError 5 | import podpodge.types.* 6 | import podpodge.Env 7 | import sttp.capabilities.pekko.PekkoStreams 8 | import sttp.capabilities.WebSockets 9 | import sttp.model.{MediaType, StatusCode} 10 | import sttp.tapir.{oneOf, oneOfVariant, Codec, CodecFormat, Endpoint, EndpointOutput, Schema, SchemaType, Validator} 11 | import sttp.tapir.generic.auto.schemaForCaseClass 12 | import sttp.tapir.json.circe.jsonBody 13 | import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter 14 | import sttp.tapir.server.ServerEndpoint 15 | import sttp.tapir.Codec.PlainCodec 16 | import zio.{Cause, Runtime, Unsafe, ZIO} 17 | import zio.prelude.* 18 | 19 | import scala.concurrent.Future 20 | import scala.xml.{Elem, XML} 21 | 22 | trait TapirSupport { 23 | implicit def stringNewtypeSchema[T <: RichNewtype[String]#Type]: Schema[T] = Schema(SchemaType.SString()) 24 | 25 | implicit def stringNewtypeValidator[T <: RichNewtype[String]#Type]: Validator[T] = Validator.pass 26 | 27 | implicit def stringNewtypeCodec[T <: RichNewtype[String]#Type](implicit 28 | equiv: Equivalence[String, T] 29 | ): Codec[String, T, CodecFormat.TextPlain] = 30 | Codec.string.map(RichNewtype.wrap(_))(RichNewtype.unwrap(_)) 31 | 32 | implicit def intNewtypeSchema[T <: RichNewtype[Int]#Type]: Schema[T] = Schema(SchemaType.SInteger()) 33 | 34 | implicit def intNewtypeValidator[T <: RichNewtype[Int]#Type](implicit 35 | equiv: Equivalence[Int, T] 36 | ): Validator[T] = 37 | // TODO: Is it possible to include Newtype.assertion validation here? 38 | Validator.min(1L).contramap(RichNewtype.unwrap(_)) 39 | 40 | implicit def intNewtypeCodec[T <: RichNewtype[Int]#Type](implicit 41 | equiv: Equivalence[Int, T] 42 | ): Codec[String, T, CodecFormat.TextPlain] = 43 | Codec.int.map(RichNewtype.wrap(_))(RichNewtype.unwrap(_)) 44 | 45 | implicit def longNewtypeSchema[T <: RichNewtype[Long]#Type]: Schema[T] = Schema(SchemaType.SInteger()) 46 | 47 | implicit def longNewtypeValidator[T <: RichNewtype[Long]#Type](implicit 48 | equiv: Equivalence[Long, T] 49 | ): Validator[T] = 50 | // TODO: Is it possible to include Newtype.assertion validation here? 51 | Validator.min(1L).contramap(RichNewtype.unwrap(_)) 52 | 53 | implicit def longNewtypeCodec[T <: RichNewtype[Long]#Type](implicit 54 | equiv: Equivalence[Long, T] 55 | ): Codec[String, T, CodecFormat.TextPlain] = 56 | Codec.long.map(RichNewtype.wrap(_))(RichNewtype.unwrap(_)) 57 | 58 | implicit val xmlCodec: Codec[String, Elem, CodecFormat.Xml] = 59 | implicitly[PlainCodec[String]].map(XML.loadString(_))(_.toString).format(CodecFormat.Xml()) 60 | 61 | implicit class RichZIOPekkoHttpEndpoint[I, O](endpoint: Endpoint[Unit, I, ApiError, O, PekkoStreams]) { 62 | 63 | def toZRoute( 64 | logic: I => ZIO[Env, Throwable, O] 65 | )(implicit interpreter: PekkoHttpServerInterpreter, runtime: Runtime[Env]): Route = 66 | interpreter.toRoute(endpoint.serverLogic { in => 67 | Unsafe.unsafe { implicit u => 68 | runtime.unsafe.runToFuture { 69 | logic(in).absorb 70 | .foldZIO( 71 | { 72 | case e: ApiError => ZIO.left(e) 73 | case t => 74 | ZIO.logErrorCause("Unhandled error occurred", Cause.die(t)) *> 75 | ZIO.left(ApiError.InternalError("Internal server error")) 76 | }, 77 | a => ZIO.right(a) 78 | ) 79 | } 80 | } 81 | }: ServerEndpoint[PekkoStreams & WebSockets, Future]) 82 | 83 | } 84 | 85 | val apiError: EndpointOutput.OneOf[ApiError, ApiError] = oneOf[ApiError]( 86 | oneOfVariant(StatusCode.NotFound, jsonBody[ApiError.NotFound]), 87 | oneOfVariant(StatusCode.BadRequest, jsonBody[ApiError.BadRequest]), 88 | oneOfVariant(StatusCode.InternalServerError, jsonBody[ApiError.InternalError]) 89 | ) 90 | 91 | val imageJpegCodec = new CodecFormat { 92 | def mediaType: MediaType = MediaType.ImageJpeg 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/types/SourceType.scala: -------------------------------------------------------------------------------- 1 | package podpodge.types 2 | 3 | import enumeratum.{CirceEnum, Enum, EnumEntry} 4 | import enumeratum.EnumEntry.LowerCamelcase 5 | 6 | sealed trait SourceType extends EnumEntry with LowerCamelcase 7 | 8 | object SourceType extends Enum[SourceType] with CirceEnum[SourceType] { 9 | case object YouTube extends SourceType 10 | case object Directory extends SourceType 11 | 12 | lazy val values: IndexedSeq[SourceType] = findValues 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/types/Tristate.scala: -------------------------------------------------------------------------------- 1 | package podpodge.types 2 | 3 | import io.circe.* 4 | import io.circe.Decoder.Result 5 | 6 | sealed trait Tristate[+A] { 7 | 8 | def toOption: Option[A] = this match { 9 | case Tristate.Unspecified | Tristate.None => None 10 | case Tristate.Some(x) => Some(x) 11 | } 12 | 13 | def specify[B >: A](specified: => Option[B]): Option[B] = this match { 14 | case Tristate.Unspecified => specified 15 | case Tristate.None => None 16 | case Tristate.Some(v) => Some(v) 17 | } 18 | } 19 | 20 | object Tristate { 21 | case object Unspecified extends Tristate[Nothing] 22 | case object None extends Tristate[Nothing] 23 | final case class Some[A](a: A) extends Tristate[A] 24 | 25 | implicit def encoder[A: Encoder]: Encoder[Tristate[A]] = new Encoder[Tristate[A]] { 26 | 27 | final def apply(a: Tristate[A]): Json = a match { 28 | case Tristate.Some(v) => implicitly[Encoder[A]].apply(v) 29 | case Tristate.None => 30 | Json.Null 31 | case Tristate.Unspecified => 32 | // I want to specify `Json.Absent` or something like that here, but not sure if it's possible 33 | Json.Null 34 | } 35 | } 36 | 37 | implicit def decoder[A: Decoder]: Decoder[Tristate[A]] = new Decoder[Tristate[A]] { 38 | final def apply(c: HCursor): Result[Tristate[A]] = tryDecode(c) 39 | 40 | final override def tryDecode(c: ACursor): Decoder.Result[Tristate[A]] = c match { 41 | case c: HCursor => 42 | if (c.value.isNull) Right(Tristate.None) 43 | else 44 | implicitly[Decoder[A]].apply(c) match { 45 | case Right(a) => Right(Tristate.Some(a)) 46 | case Left(f) => Left(f) 47 | } 48 | 49 | case c: FailedCursor => 50 | if (!c.incorrectFocus) Right(Tristate.Unspecified) 51 | else Left(DecodingFailure("Unable to decode Tristate", c.history)) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/types/package.scala: -------------------------------------------------------------------------------- 1 | package podpodge 2 | 3 | import io.circe.{Decoder, Encoder} 4 | import zio.prelude.* 5 | import zio.prelude.Assertion.* 6 | 7 | package object types { 8 | 9 | abstract class RichNewtype[A: Encoder: Decoder] extends Newtype[A] { self => 10 | implicit val equiv: A <=> Type = Equivalence(wrap, unwrap) 11 | 12 | implicit val encoder: Encoder[Type] = implicitly[Encoder[A]].contramap(unwrap) 13 | implicit val decoder: Decoder[Type] = implicitly[Decoder[A]].map(wrap) 14 | 15 | implicit final class UnwrapOps(value: Type) { 16 | def unwrap: A = self.unwrap(value) 17 | } 18 | 19 | def makeUnsafe(value: A): Type = 20 | make(value).fold(e => throw new IllegalArgumentException(e.mkString("; ")), identity) 21 | } 22 | 23 | object RichNewtype { 24 | 25 | def wrap[FROM, TO](a: FROM)(implicit equiv: Equivalence[FROM, TO]): TO = 26 | implicitly[Equivalence[FROM, TO]].to(a) 27 | 28 | def unwrap[FROM, TO](a: TO)(implicit equiv: Equivalence[FROM, TO]): FROM = 29 | implicitly[Equivalence[FROM, TO]].from(a) 30 | } 31 | 32 | abstract class TaggedId extends RichNewtype[Long] { 33 | def empty: Option[Type] = None 34 | } 35 | 36 | object PodcastId extends TaggedId 37 | type PodcastId = PodcastId.Type 38 | 39 | object EpisodeId extends TaggedId 40 | type EpisodeId = EpisodeId.Type 41 | 42 | object ConfigurationId extends TaggedId 43 | type ConfigurationId = ConfigurationId.Type 44 | 45 | object YouTubeApiKey extends RichNewtype[String] { 46 | val configKey: String = "PODPODGE_YOUTUBE_API_KEY" 47 | } 48 | type YouTubeApiKey = YouTubeApiKey.Type 49 | 50 | object ServerHost extends RichNewtype[String] { 51 | val configKey: String = "PODPODGE_HOST" 52 | } 53 | type ServerHost = ServerHost.Type 54 | 55 | object ServerPort extends RichNewtype[Int] { 56 | val configKey: String = "PODPODGE_PORT" 57 | 58 | override def assertion = assert(greaterThanOrEqualTo(0) && lessThanOrEqualTo(65353)) 59 | } 60 | type ServerPort = ServerPort.Type 61 | 62 | object ServerScheme extends RichNewtype[String] { 63 | val configKey: String = "PODPODGE_SCHEME" 64 | } 65 | type ServerScheme = ServerScheme.Type 66 | 67 | object DownloaderPath extends RichNewtype[String] { 68 | val configKey: String = "PODPODGE_DOWNLOADER_PATH" 69 | } 70 | type DownloaderPath = DownloaderPath.Type 71 | 72 | } 73 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/util/FileExtensions.scala: -------------------------------------------------------------------------------- 1 | package podpodge.util 2 | 3 | import java.io.File 4 | 5 | object FileExtensions { 6 | 7 | implicit class FileExtension(val self: File) extends AnyVal { 8 | 9 | def extension: Option[String] = { 10 | val name = self.getName 11 | val dotIndex = name.lastIndexOf(".") 12 | Option.when(dotIndex >= 0)(name.substring(dotIndex + 1)) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/youtube/PlaylistItemListResponse.scala: -------------------------------------------------------------------------------- 1 | package podpodge.youtube 2 | 3 | import io.circe.generic.semiauto.* 4 | import io.circe.Decoder 5 | 6 | import java.time.OffsetDateTime 7 | 8 | final case class PlaylistItemListResponse( 9 | items: List[PlaylistItem], 10 | nextPageToken: Option[String], 11 | pageInfo: PageInfo 12 | ) 13 | 14 | object PlaylistItemListResponse { 15 | implicit val decoder: Decoder[PlaylistItemListResponse] = deriveDecoder[PlaylistItemListResponse] 16 | } 17 | 18 | final case class PlaylistItem(id: String, snippet: PlaylistItemSnippet, contentDetails: PlaylistItemContentDetails) { 19 | def isPrivate: Boolean = contentDetails.videoPublishedAt.isEmpty 20 | } 21 | 22 | object PlaylistItem { 23 | implicit val decoder: Decoder[PlaylistItem] = deriveDecoder[PlaylistItem] 24 | } 25 | 26 | final case class PlaylistItemSnippet( 27 | publishedAt: OffsetDateTime, 28 | channelId: String, 29 | title: String, 30 | description: String, 31 | thumbnails: Thumbnails, 32 | channelTitle: String, 33 | playlistId: String, 34 | resourceId: ResourceId 35 | ) 36 | 37 | object PlaylistItemSnippet { 38 | implicit val decoder: Decoder[PlaylistItemSnippet] = deriveDecoder[PlaylistItemSnippet] 39 | } 40 | 41 | final case class Thumbnails( 42 | default: Option[Thumbnail], 43 | medium: Option[Thumbnail], 44 | high: Option[Thumbnail], 45 | standard: Option[Thumbnail], 46 | maxres: Option[Thumbnail] 47 | ) { 48 | def highestRes: Option[Thumbnail] = maxres.orElse(high).orElse(standard).orElse(medium).orElse(default) 49 | } 50 | 51 | object Thumbnails { 52 | implicit val decoder: Decoder[Thumbnails] = deriveDecoder[Thumbnails] 53 | } 54 | 55 | final case class Thumbnail(url: String, width: Int, height: Int) 56 | 57 | object Thumbnail { 58 | implicit val decoder: Decoder[Thumbnail] = deriveDecoder[Thumbnail] 59 | } 60 | 61 | final case class ResourceId(kind: String, videoId: String) 62 | 63 | object ResourceId { 64 | implicit val decoder: Decoder[ResourceId] = deriveDecoder[ResourceId] 65 | } 66 | 67 | final case class PlaylistItemContentDetails(videoId: String, videoPublishedAt: Option[OffsetDateTime]) 68 | 69 | object PlaylistItemContentDetails { 70 | implicit val decoder: Decoder[PlaylistItemContentDetails] = deriveDecoder[PlaylistItemContentDetails] 71 | } 72 | 73 | final case class PageInfo(totalResults: Int, resultsPerPage: Int) 74 | 75 | object PageInfo { 76 | implicit val decoder: Decoder[PageInfo] = deriveDecoder[PageInfo] 77 | } 78 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/youtube/PlaylistListResponse.scala: -------------------------------------------------------------------------------- 1 | package podpodge.youtube 2 | 3 | import io.circe.generic.semiauto.* 4 | import io.circe.Decoder 5 | 6 | import java.time.OffsetDateTime 7 | 8 | final case class PlaylistListResponse( 9 | items: List[Playlist], 10 | nextPageToken: Option[String], 11 | pageInfo: PageInfo 12 | ) 13 | 14 | object PlaylistListResponse { 15 | implicit val decoder: Decoder[PlaylistListResponse] = deriveDecoder[PlaylistListResponse] 16 | } 17 | 18 | final case class Playlist( 19 | id: String, 20 | snippet: PlaylistSnippet, 21 | contentDetails: PlaylistContentDetails 22 | ) 23 | 24 | object Playlist { 25 | implicit val decoder: Decoder[Playlist] = deriveDecoder[Playlist] 26 | } 27 | 28 | final case class PlaylistSnippet( 29 | publishedAt: OffsetDateTime, 30 | channelId: String, 31 | title: String, 32 | description: String, 33 | thumbnails: Thumbnails, 34 | channelTitle: String 35 | ) 36 | 37 | object PlaylistSnippet { 38 | implicit val decoder: Decoder[PlaylistSnippet] = deriveDecoder[PlaylistSnippet] 39 | } 40 | 41 | final case class PlaylistContentDetails(itemCount: Int) 42 | 43 | object PlaylistContentDetails { 44 | implicit val decoder: Decoder[PlaylistContentDetails] = deriveDecoder[PlaylistContentDetails] 45 | } 46 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/youtube/YouTubeClient.scala: -------------------------------------------------------------------------------- 1 | package podpodge.youtube 2 | 3 | import podpodge.http.Sttp 4 | import podpodge.types.YouTubeApiKey 5 | import sttp.client3.* 6 | import sttp.client3.circe.* 7 | import sttp.model.{Header, MediaType} 8 | import zio.stream.ZStream 9 | import zio.Chunk 10 | 11 | object YouTubeClient { 12 | 13 | def listPlaylists( 14 | ids: Seq[String], 15 | youTubeApiKey: YouTubeApiKey 16 | ): ZStream[Sttp, Throwable, Playlist] = 17 | ZStream.paginateChunkZIO(Option.empty[String]) { pageToken => 18 | val request = basicRequest 19 | .get( 20 | uri"https://www.googleapis.com/youtube/v3/playlists".withParams( 21 | Map( 22 | "key" -> youTubeApiKey.unwrap, 23 | "id" -> ids.mkString(","), 24 | "part" -> "snippet,contentDetails,id", 25 | "maxResults" -> "50" 26 | ) ++ pageToken.map("pageToken" -> _).toMap 27 | ) 28 | ) 29 | .headers(Header.contentType(MediaType.ApplicationJson)) 30 | .response(asJson[PlaylistListResponse]) 31 | 32 | Sttp.send(request).map(_.body).absolve.map { r => 33 | (Chunk.fromIterable(r.items), r.nextPageToken.map(Some(_))) 34 | } 35 | } 36 | 37 | def listPlaylistItems( 38 | playlistId: String, 39 | youTubeApiKey: YouTubeApiKey 40 | ): ZStream[Sttp, Throwable, PlaylistItem] = 41 | ZStream 42 | .paginateChunkZIO(Option.empty[String]) { pageToken => 43 | val request = basicRequest 44 | .get( 45 | uri"https://www.googleapis.com/youtube/v3/playlistItems".withParams( 46 | Map( 47 | "key" -> youTubeApiKey.unwrap, 48 | "playlistId" -> playlistId, 49 | "part" -> "snippet,contentDetails,id", 50 | "maxResults" -> "50" 51 | ) ++ pageToken.map("pageToken" -> _).toMap 52 | ) 53 | ) 54 | .headers(Header.contentType(MediaType.ApplicationJson)) 55 | .response(asJson[PlaylistItemListResponse]) 56 | 57 | Sttp.send(request).map(_.body).absolve.map { r => 58 | (Chunk.fromIterable(r.items), r.nextPageToken.map(Some(_))) 59 | } 60 | } 61 | .filterNot(_.isPrivate) 62 | 63 | } 64 | -------------------------------------------------------------------------------- /core/src/main/scala/podpodge/youtube/YouTubeDL.scala: -------------------------------------------------------------------------------- 1 | package podpodge.youtube 2 | 3 | import podpodge.types.* 4 | import podpodge.StaticConfig 5 | import zio.{Task, ZIO} 6 | import zio.process.Command 7 | 8 | import java.io.File 9 | import java.nio.file.Files 10 | 11 | object YouTubeDL { 12 | 13 | def download( 14 | podcastId: PodcastId, 15 | videoId: String, 16 | downloaderPathOpt: Option[DownloaderPath] 17 | ): Task[File] = { 18 | // TODO: Support other audio formats in the future. Note that `EpisodeController` and so on 19 | // will have to be updated as well since "mp3" is hardcoded there. 20 | val audioFormat = "mp3" 21 | val podcastAudioDirectory = StaticConfig.audioPath.resolve(podcastId.unwrap.toString) 22 | val outputFile = podcastAudioDirectory.resolve(s"$videoId.$audioFormat").toFile 23 | val downloaderPath = downloaderPathOpt.getOrElse(DownloaderPath("youtube-dl")) 24 | 25 | if (outputFile.exists) { 26 | ZIO.logInfo(s"${outputFile.getName} already exists. Skipping download.").as(outputFile) 27 | } else { 28 | for { 29 | workingDirectory <- ZIO.attempt(Files.createDirectories(podcastAudioDirectory)) 30 | // Pass a URL to youtube-dl instead of just the videoId because YouTube's IDs can start with a hyphen which 31 | // confuses youtube-dl into thinking it's a command-line option. 32 | videoUrl = s"https://www.youtube.com/watch?v=$videoId" 33 | // VBR can cause slowness with seeks in podcast apps, so we use a constant bitrate instead. 34 | _ <- Command( 35 | downloaderPath.unwrap, 36 | "--no-call-home", 37 | "--extract-audio", 38 | "--audio-format", 39 | audioFormat, 40 | "--audio-quality", 41 | "128K", 42 | "--output", 43 | outputFile.getName, 44 | videoUrl 45 | ).workingDirectory(workingDirectory.toFile).inheritIO.successfulExitCode 46 | } yield outputFile 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /flyway.conf: -------------------------------------------------------------------------------- 1 | flyway.url=jdbc:sqlite:data/podpodge.db 2 | flyway.user= 3 | flyway.password= 4 | flyway.locations=filesystem:migration 5 | -------------------------------------------------------------------------------- /migration/V1__create-tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE podcast 2 | ( 3 | id integer primary key autoincrement, 4 | external_source text not null, 5 | title text not null, 6 | description text not null, 7 | category text not null, 8 | generator text not null, 9 | last_build_date text not null, 10 | publish_date text not null, 11 | author text not null, 12 | subtitle text not null, 13 | summary text not null, 14 | image text, 15 | last_check_date text 16 | ); 17 | 18 | CREATE TABLE episode 19 | ( 20 | id integer primary key autoincrement, 21 | podcast_id integer not null, 22 | guid text not null unique, 23 | external_source text not null, 24 | title text not null, 25 | publish_date text not null, 26 | image text, 27 | media_file text, 28 | duration integer, 29 | foreign key (podcast_id) references podcast (id) on delete cascade 30 | ); -------------------------------------------------------------------------------- /migration/V2__source-type.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE podcast 2 | ADD source_type text not null default 'youTube'; 3 | -------------------------------------------------------------------------------- /migration/V3__configuration.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE configuration 2 | ( 3 | id integer primary key autoincrement, 4 | you_tube_api_key text, 5 | server_host text, 6 | server_port text, 7 | server_scheme text 8 | ); 9 | -------------------------------------------------------------------------------- /migration/V4__configuration-downloader.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE configuration 2 | ADD downloader_path text null; 3 | 4 | ALTER TABLE episode RENAME TO temp_episode; 5 | 6 | CREATE TABLE episode 7 | ( 8 | id integer primary key autoincrement, 9 | podcast_id integer not null, 10 | guid text not null, 11 | external_source text not null, 12 | title text not null, 13 | publish_date text not null, 14 | image text, 15 | media_file text, 16 | duration integer, 17 | foreign key (podcast_id) references podcast (id) on delete cascade 18 | ); 19 | 20 | INSERT INTO episode (id, podcast_id, guid, external_source, title, publish_date, image, media_file, duration) 21 | SELECT id, podcast_id, guid, external_source, title, publish_date, image, media_file, duration 22 | FROM temp_episode; 23 | 24 | DROP TABLE temp_episode; 25 | -------------------------------------------------------------------------------- /project/Build.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | 4 | import scala.Console 5 | 6 | object Build { 7 | val ScalaVersion = "2.13.12" 8 | 9 | val PodpodgeVersion = "0.2.0" 10 | 11 | lazy val ScalacOptions = Seq( 12 | "-encoding", 13 | "UTF-8", 14 | "-unchecked", 15 | "-deprecation", 16 | "-feature", 17 | "-language:postfixOps", 18 | "-language:implicitConversions", 19 | "-language:higherKinds", 20 | "-Xsource:3", 21 | "-Xfatal-warnings", 22 | "-Ymacro-annotations", 23 | "-Xlint:nullary-unit", // Warn when nullary methods return Unit. 24 | "-Xlint:inaccessible", // Warn about inaccessible types in method signatures. 25 | "-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id. 26 | "-Xlint:doc-detached", // A Scaladoc comment appears to be detached from its element. 27 | "-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field. 28 | "-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope. 29 | "-Xlint:delayedinit-select", // Selecting member of DelayedInit. 30 | "-Xlint:stars-align", // Pattern sequence wildcard must align with sequence component. 31 | "-Xlint:option-implicit", // Option.apply used implicit view. 32 | "-Xlint:poly-implicit-overload", // Parameterized overloaded implicit methods are not visible as view bounds. 33 | "-Ywarn-extra-implicit" // Warn when more than one implicit parameter section is defined. 34 | ) ++ 35 | Seq( 36 | "-Ywarn-unused:imports", // Warn if an import selector is not referenced. 37 | "-Ywarn-unused:locals", // Warn if a local definition is unused. 38 | "-Ywarn-unused:privates", // Warn if a private member is unused. 39 | "-Ywarn-unused:implicits" // Warn if an implicit parameter is unused. 40 | ).filter(_ => !lenientDevEnabled) 41 | 42 | def defaultSettings(projectName: String) = 43 | Seq( 44 | name := projectName, 45 | Test / javaOptions += "-Duser.timezone=UTC", 46 | scalacOptions := ScalacOptions, 47 | javaOptions += "-Dfile.encoding=UTF-8", 48 | ThisBuild / scalaVersion := ScalaVersion, 49 | libraryDependencies ++= Plugins.BaseCompilerPlugins, 50 | incOptions ~= (_.withLogRecompileOnMacro(false)), 51 | autoAPIMappings := true, 52 | testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), 53 | Test / fork := true, 54 | Test / logBuffered := false 55 | ) 56 | 57 | def compilerFlag(key: String, default: Boolean): Boolean = { 58 | val flag = sys.props.get(key).orElse { 59 | val envVarName = key.replace('.', '_').toUpperCase 60 | sys.env.get(envVarName) 61 | } 62 | 63 | val flagValue = flag.map(_.toBoolean).getOrElse(default) 64 | 65 | println(s"${scala.Console.MAGENTA}$key:${scala.Console.RESET} $flagValue") 66 | 67 | flagValue 68 | } 69 | 70 | /** Uses more lenient rules for local development so that warnings for unused 71 | * imports and so on doesn't get in your way when code is still a work in 72 | * progress. CI has all the strict rules enabled. 73 | */ 74 | lazy val lenientDevEnabled: Boolean = compilerFlag("scalac.lenientDev.enabled", true) 75 | 76 | } 77 | -------------------------------------------------------------------------------- /project/Plugins.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Plugins { 4 | 5 | lazy val BaseCompilerPlugins = Seq( 6 | compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /project/Version.scala: -------------------------------------------------------------------------------- 1 | object V { 2 | val pekko = "1.0.3" 3 | 4 | val pekkoHttp = "1.0.0" 5 | 6 | val circe = "0.14.6" 7 | 8 | val enumeratum = "1.7.3" 9 | 10 | val flyway = "9.16.0" 11 | 12 | val quill = "4.8.0" 13 | 14 | val scalaXml = "2.2.0" 15 | 16 | val slf4j = "2.0.9" 17 | 18 | val sqliteJdbc = "3.41.0.0" 19 | 20 | val sttp = "3.9.1" 21 | 22 | val tapir = "1.9.4" 23 | 24 | val zio = "2.0.19" 25 | 26 | val zioLogging = "2.1.16" 27 | 28 | val zioPrelude = "1.0.0-RC21" 29 | 30 | val zioProcess = "0.7.2" 31 | } 32 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 2 | 3 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") 4 | 5 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.5") 6 | 7 | addSbtPlugin("com.github.reibitto" % "sbt-welcome" % "0.4.0") 8 | 9 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") 10 | 11 | // Scalafix (enable only when needed) 12 | //addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.1") 13 | --------------------------------------------------------------------------------