├── db └── .gitkeep ├── migrations ├── .keep ├── sqlite │ ├── 2023-03-27-085348_podcast_original │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-05-07-193017_persist-filter │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-08-210254_notifications │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-25-221424_cleaned_url_removal │ │ ├── up.sql │ │ └── down.sql │ ├── 2024-11-29-083801_tags │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-02-100903_create_podcast_table │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-23-210248_likepodcasts │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-07-201826_index_url_download │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-23-083222_playlist │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-05-155720_episode_clean_url │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-01-05-150725_favorite_podcast_episodes │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-10-25-082817_podcast_episode_chapters │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-28-094450_settings_direct_paths │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-05-20-175610_only_favored_podcasts_filter │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-10-214350_podcast_episode_add_guid │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-01-165632_delete-flag-episodes │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-05-08-112006_config-initial-podcast-load │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-02-14-222819_unique_guid │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-12-28-114101_user_api_key │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-04-23-115251_gpodder_api │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-02-101007_create_podcast_episode_table │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-04-16-085031_podcast_names │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-26-142313_settings │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-27-143946_podcast_episode_file_paths │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-08-25-170551_episode_numbering │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-04-09-122459_user_management │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-01-10-163832_download_location │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-25-104928_podcast_extra │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-05-14-184857_podcast-name-adjustment │ │ ├── down.sql │ │ └── up.sql │ └── 2023-05-03-183844_search │ │ ├── up.sql │ │ └── down.sql ├── postgres │ ├── 2024-11-29-083801_tags │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-25-221424_cleaned_url_removal │ │ ├── up.sql │ │ └── down.sql │ ├── 2023-12-28-114101_user_api_key │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-23-083222_playlist │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-10-25-082817_podcast_episode_chapters │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-05-155720_episode_clean_url │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-01-05-150725_favorite_podcast_episodes │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-10-214350_podcast_episode_add_guid │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-01-165632_delete-flag-episodes │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-28-094450_settings_direct_paths │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-03-02-100903_create_podcast_table │ │ └── down.sql │ ├── 2025-02-14-222819_unique_guid │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-08-25-170551_episode_numbering │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-27-143946_podcast_episode_file_paths │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-08-200149_enlarge-url-columns │ │ ├── up.sql │ │ └── down.sql │ └── 2025-01-10-163832_download_location │ │ ├── down.sql │ │ └── up.sql └── mysql │ └── 2023-03-02-100903_create_podcast_table │ └── down.sql ├── docs ├── .gitignore ├── src │ ├── tutorials │ │ ├── OIDC.md │ │ ├── login.png │ │ ├── adding_device.png │ │ ├── gpodder_sync.png │ │ └── add_server_url.png │ ├── images │ │ ├── home.png │ │ ├── search.png │ │ ├── Info_Page.png │ │ ├── podcast.png │ │ ├── settings.png │ │ ├── timeline.png │ │ ├── basic_auth.png │ │ ├── fullscreen.png │ │ ├── administration.png │ │ ├── podcast_view.png │ │ ├── settings_menu.png │ │ ├── continue_listening.png │ │ └── advanced_audio_player.png │ ├── rss_feed.md │ ├── I18n.md │ ├── SUMMARY.md │ ├── Introduction.md │ ├── Contributing.md │ ├── CLI.md │ ├── podindex.md │ ├── FAQ.md │ ├── S3.md │ ├── UIWalkthrough.md │ └── HOSTING.md ├── default.jpg └── book.toml ├── src ├── config │ ├── dbconfig.rs │ └── mod.rs ├── exception │ ├── exceptions.rs │ └── mod.rs ├── service │ ├── subscription.rs │ ├── websocket_service.rs │ ├── mod.rs │ ├── notification_service.rs │ ├── logging_service.rs │ ├── podcast_chapter.rs │ └── telegram_api.rs ├── domain │ ├── mod.rs │ └── models │ │ ├── mod.rs │ │ └── device │ │ ├── mod.rs │ │ └── model.rs ├── models │ ├── device_subscription.rs │ ├── subscription_changes_from_client.rs │ ├── opml_model.rs │ ├── dto_models.rs │ ├── podcast_rssadd_model.rs │ ├── gpodder_available_podcasts.rs │ ├── search_type.rs │ ├── color.rs │ ├── mod.rs │ ├── misc_models.rs │ └── order_criteria.rs ├── commands │ └── mod.rs ├── application │ ├── services │ │ ├── mod.rs │ │ └── device │ │ │ ├── mod.rs │ │ │ └── service.rs │ ├── usecases │ │ ├── mod.rs │ │ └── devices │ │ │ ├── mod.rs │ │ │ ├── edit_use_case.rs │ │ │ ├── create_use_case.rs │ │ │ └── query_use_case.rs │ ├── repositories │ │ ├── mod.rs │ │ └── device_repository.rs │ └── mod.rs ├── constants │ └── mod.rs ├── gpodder │ ├── auth │ │ └── mod.rs │ ├── device │ │ ├── dto │ │ │ ├── mod.rs │ │ │ └── device_post.rs │ │ └── mod.rs │ ├── episodes │ │ └── mod.rs │ ├── subscription │ │ └── mod.rs │ ├── mod.rs │ └── parametrization.rs ├── adapters │ ├── api │ │ ├── controllers │ │ │ └── mod.rs │ │ ├── mod.rs │ │ └── models │ │ │ ├── mod.rs │ │ │ └── device │ │ │ ├── mod.rs │ │ │ ├── device_create.rs │ │ │ └── device_response.rs │ ├── persistence │ │ ├── model │ │ │ ├── mod.rs │ │ │ └── device │ │ │ │ ├── mod.rs │ │ │ │ └── device_entity.rs │ │ ├── repositories │ │ │ ├── mod.rs │ │ │ └── device │ │ │ │ └── mod.rs │ │ └── mod.rs │ ├── mod.rs │ └── file │ │ └── mod.rs ├── utils │ ├── test_builder │ │ ├── mod.rs │ │ ├── device_test_builder.rs │ │ ├── notification_test_builder.rs │ │ └── user_test_builder.rs │ ├── environment_variables.rs │ ├── mod.rs │ ├── do_retry.rs │ ├── reqwest_client.rs │ ├── gpodder_trimmer.rs │ ├── http_client.rs │ ├── rss_feed_parser.rs │ └── auth.rs ├── controllers │ ├── mod.rs │ ├── controller_utils.rs │ ├── file_hosting.rs │ └── manifest_controller.rs ├── mutex.rs └── test_utils.rs ├── .dockerignore ├── funding.yml ├── ui ├── src │ ├── models │ │ ├── DiskModel.ts │ │ ├── Playlist.ts │ │ ├── SearchLinkBuilder.ts │ │ ├── constants.ts │ │ ├── AuthModel.ts │ │ ├── error.ts │ │ ├── PodcastSettingsUpdate.ts │ │ ├── SysUser.ts │ │ ├── AddTypes.ts │ │ ├── User.ts │ │ ├── CPUModel.ts │ │ ├── Filter.ts │ │ ├── Order.ts │ │ ├── PodcastTags.tsx │ │ ├── Tags.ts │ │ ├── PodcastWatchedModel.tsx │ │ ├── EpisodesWithOptionalTimeline.ts │ │ ├── Episode.ts │ │ ├── Setting.tsx │ │ ├── TimeLineModel.ts │ │ ├── PodcastWatchedEpisodeModel.ts │ │ ├── PodcastSetting.ts │ │ ├── PodcastAddModel.ts │ │ ├── PodcastDefaultSettings.tsx │ │ ├── AudioAmplifier.ts │ │ ├── messages │ │ │ └── BroadcastMesage.ts │ │ ├── socketioEvents.ts │ │ └── SysInfo.ts │ ├── vite-env.d.ts │ ├── hooks │ │ ├── useLoadPodcastEpisodeForPlayback.ts │ │ ├── useOnMount.ts │ │ └── useKeyDown.ts │ ├── components │ │ ├── ui │ │ │ ├── ChartLoadingSkeleton.tsx │ │ │ ├── LoadingPodcastCard.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── LoadingSkeletonDD.tsx │ │ │ └── LoadingSkeletonSpan.tsx │ │ ├── Heading3.tsx │ │ ├── Heading1.tsx │ │ ├── Heading2.tsx │ │ ├── OIDCButton.tsx │ │ ├── Header.tsx │ │ ├── RefreshIcon.tsx │ │ ├── Loading.tsx │ │ ├── MainContentPanel.tsx │ │ ├── CustomButtonSecondary.tsx │ │ ├── I18nDropdown.tsx │ │ ├── CustomCheckbox.tsx │ │ ├── CustomInput.tsx │ │ ├── AudioPlayer.tsx │ │ ├── SettingsInfoIcon.tsx │ │ ├── CustomButtonPrimary.tsx │ │ ├── SidebarItem.tsx │ │ ├── PlaylistData.tsx │ │ ├── PlaylistSubmitViewer.tsx │ │ ├── ConfirmModal.tsx │ │ ├── Spinner.tsx │ │ ├── AddPodcastModal.tsx │ │ ├── Switcher.tsx │ │ ├── FeedURLComponent.tsx │ │ └── PlayerEpisodeInfo.tsx │ ├── lib │ │ └── utils.ts │ ├── utils │ │ ├── decodingUtilities.ts │ │ ├── config.ts │ │ ├── ErrorDefinition.ts │ │ ├── audioPlayer.ts │ │ ├── navigationUtils.ts │ │ ├── useDebounce.ts │ │ ├── ErrorSnackBarResponses.ts │ │ ├── FileUtils.ts │ │ ├── AnimationFrameHook.ts │ │ ├── PlayHandler.ts │ │ ├── login.ts │ │ └── LazyLoading.ts │ ├── icons │ │ ├── MaginifyingGlassIcon.tsx │ │ ├── InfoIcon.tsx │ │ ├── LanguageIcon.tsx │ │ ├── RefreshIcon.tsx │ │ ├── PlayIcon.tsx │ │ ├── BellIcon.tsx │ │ ├── HeartIcon.tsx │ │ ├── PodFetchIcon.tsx │ │ ├── CloudIcon.tsx │ │ ├── PlusIcon.tsx │ │ ├── PodcastIcon.tsx │ │ └── VolumeIcon.tsx │ ├── pages │ │ ├── EpisodeSearchPage.tsx │ │ ├── HomePageSelector.tsx │ │ └── PlaylistDetailPage.tsx │ ├── store │ │ ├── ModalSlice.ts │ │ ├── opmlImportSlice.ts │ │ └── PlaylistSlice.ts │ ├── language │ │ └── i18n.ts │ ├── routing │ │ └── Root.tsx │ └── App.css ├── funding.yml ├── public │ ├── logo.png │ ├── default.jpg │ ├── favicon.ico │ ├── pwa-64x64.png │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ ├── apple-touch-icon-180x180.png │ ├── microphone.svg │ └── vite.svg ├── postcss.config.cjs ├── README.md ├── pnpm-workspace.yaml ├── vitest.config.ts ├── tsconfig.node.json ├── .gitignore ├── test │ └── HtmlCodeReplacement.test.spec.ts ├── components.json ├── index.html └── tsconfig.json ├── setup └── terraform │ ├── postgres-docker │ ├── samples │ │ ├── dynamic.toml │ │ └── traefik.toml │ ├── start-db │ │ ├── variables.tf │ │ └── postgres.tf │ ├── prepare-traefik │ │ ├── variables.tf │ │ └── main.tf │ ├── start-podfetch │ │ ├── variables.tf │ │ └── main.tf │ ├── start-traefik │ │ ├── variables.tf │ │ └── start-traefik.tf │ ├── variables.tf │ └── setup.tf │ ├── sqlite-docker │ ├── samples │ │ ├── dynamic.toml │ │ └── traefik.toml │ ├── start-podfetch │ │ ├── variables.tf │ │ └── main.tf │ ├── prepare-traefik │ │ ├── variables.tf │ │ └── main.tf │ ├── start-traefik │ │ ├── variables.tf │ │ └── start-traefik.tf │ ├── variables.tf │ └── setup.tf │ └── README.md ├── dummy.rs ├── diesel.toml ├── .gitignore ├── docker-bake.hcl ├── docker-compose.yml ├── .github ├── workflows │ ├── dependabot-automerge.yml │ ├── build-documentation.yml │ └── checkFrontend.yml └── dependabot.yml ├── Cross-postgres.toml ├── docker-compose-postgres.yml ├── Dockerfile_cross_postgres └── README.md /db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/src/tutorials/OIDC.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/config/dbconfig.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /funding.yml: -------------------------------------------------------------------------------- 1 | ko_fi: samtv12345 2 | -------------------------------------------------------------------------------- /src/exception/exceptions.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/service/subscription.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/models/DiskModel.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/src/models/Playlist.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | -------------------------------------------------------------------------------- /src/models/device_subscription.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/service/websocket_service.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/funding.yml: -------------------------------------------------------------------------------- 1 | ko_fi: samtv12345 2 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod startup; 2 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dbconfig; 2 | -------------------------------------------------------------------------------- /src/domain/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device; 2 | -------------------------------------------------------------------------------- /src/exception/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod exceptions; 2 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/samples/dynamic.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/samples/dynamic.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/application/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device; 2 | -------------------------------------------------------------------------------- /src/application/usecases/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod devices; 2 | -------------------------------------------------------------------------------- /src/constants/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod inner_constants; 2 | -------------------------------------------------------------------------------- /src/domain/models/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod model; 2 | -------------------------------------------------------------------------------- /src/gpodder/auth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod authentication; 2 | -------------------------------------------------------------------------------- /src/models/subscription_changes_from_client.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/adapters/api/controllers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod routes; 2 | -------------------------------------------------------------------------------- /src/adapters/persistence/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device; 2 | -------------------------------------------------------------------------------- /src/gpodder/device/dto/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_post; 2 | -------------------------------------------------------------------------------- /src/gpodder/episodes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod gpodder_episodes; 2 | -------------------------------------------------------------------------------- /src/gpodder/subscription/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod subscriptions; 2 | -------------------------------------------------------------------------------- /src/application/services/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod service; 2 | -------------------------------------------------------------------------------- /ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /dummy.rs: -------------------------------------------------------------------------------- 1 | pub fn main() { 2 | println!("Hello, world!"); 3 | } -------------------------------------------------------------------------------- /src/adapters/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod models; 3 | -------------------------------------------------------------------------------- /src/adapters/persistence/repositories/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device; 2 | -------------------------------------------------------------------------------- /src/application/repositories/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_repository; 2 | -------------------------------------------------------------------------------- /src/adapters/persistence/model/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_entity; 2 | -------------------------------------------------------------------------------- /src/gpodder/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_controller; 2 | pub mod dto; 3 | -------------------------------------------------------------------------------- /ui/src/models/SearchLinkBuilder.ts: -------------------------------------------------------------------------------- 1 | export class SearchLinkBuilder{ 2 | 3 | } -------------------------------------------------------------------------------- /src/adapters/api/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device; 2 | pub mod podcast_episode_dto; 3 | -------------------------------------------------------------------------------- /src/adapters/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod file; 3 | pub mod persistence; 4 | -------------------------------------------------------------------------------- /src/adapters/persistence/repositories/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_repository; 2 | -------------------------------------------------------------------------------- /docs/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/default.jpg -------------------------------------------------------------------------------- /src/adapters/api/models/device/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_create; 2 | pub mod device_response; 3 | -------------------------------------------------------------------------------- /src/application/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod repositories; 2 | pub mod services; 3 | pub mod usecases; 4 | -------------------------------------------------------------------------------- /ui/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/logo.png -------------------------------------------------------------------------------- /src/adapters/persistence/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dbconfig; 2 | pub mod model; 3 | pub mod repositories; 4 | -------------------------------------------------------------------------------- /ui/public/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/default.jpg -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/src/models/constants.ts: -------------------------------------------------------------------------------- 1 | export const ADMIN_ROLE = 'admin' 2 | export const USER_ROLE = 'user' -------------------------------------------------------------------------------- /docs/src/images/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/home.png -------------------------------------------------------------------------------- /ui/public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/pwa-64x64.png -------------------------------------------------------------------------------- /docs/src/images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/search.png -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-27-085348_podcast_original/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` -------------------------------------------------------------------------------- /ui/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/pwa-192x192.png -------------------------------------------------------------------------------- /ui/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/pwa-512x512.png -------------------------------------------------------------------------------- /docs/src/images/Info_Page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/Info_Page.png -------------------------------------------------------------------------------- /docs/src/images/podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/podcast.png -------------------------------------------------------------------------------- /docs/src/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/settings.png -------------------------------------------------------------------------------- /docs/src/images/timeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/timeline.png -------------------------------------------------------------------------------- /docs/src/tutorials/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/tutorials/login.png -------------------------------------------------------------------------------- /ui/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/hooks/useLoadPodcastEpisodeForPlayback.ts: -------------------------------------------------------------------------------- 1 | export const useLoadPodcastEpisodeForPlayback = ()=>{ 2 | 3 | } -------------------------------------------------------------------------------- /docs/src/images/basic_auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/basic_auth.png -------------------------------------------------------------------------------- /docs/src/images/fullscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/fullscreen.png -------------------------------------------------------------------------------- /ui/src/models/AuthModel.ts: -------------------------------------------------------------------------------- 1 | export interface AuthModel { 2 | username: String, 3 | password: String, 4 | } 5 | -------------------------------------------------------------------------------- /docs/src/images/administration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/administration.png -------------------------------------------------------------------------------- /docs/src/images/podcast_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/podcast_view.png -------------------------------------------------------------------------------- /docs/src/images/settings_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/settings_menu.png -------------------------------------------------------------------------------- /src/application/usecases/devices/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create_use_case; 2 | pub mod edit_use_case; 3 | pub mod query_use_case; 4 | -------------------------------------------------------------------------------- /ui/src/models/error.ts: -------------------------------------------------------------------------------- 1 | type ErrorModel = { 2 | message: string, 3 | error: string 4 | code: number 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/tutorials/adding_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/tutorials/adding_device.png -------------------------------------------------------------------------------- /docs/src/tutorials/gpodder_sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/tutorials/gpodder_sync.png -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-07-193017_persist-filter/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE filters; -------------------------------------------------------------------------------- /docs/src/images/continue_listening.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/continue_listening.png -------------------------------------------------------------------------------- /docs/src/tutorials/add_server_url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/tutorials/add_server_url.png -------------------------------------------------------------------------------- /migrations/postgres/2024-11-29-083801_tags/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE tags; 3 | DROP TABLE -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-08-210254_notifications/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE notifications; -------------------------------------------------------------------------------- /migrations/sqlite/2023-11-25-221424_cleaned_url_removal/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE episodes DROP COLUMN cleaned_url; -------------------------------------------------------------------------------- /migrations/sqlite/2024-11-29-083801_tags/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE tags; 3 | DROP TABLE -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # UI frontend for Podfetch 2 | 3 | ## Features 4 | 5 | * Search and download podcasts 6 | * Listen to podcasts 7 | -------------------------------------------------------------------------------- /ui/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@tailwindcss/oxide' 3 | - core-js 4 | - esbuild 5 | packages: 6 | - . -------------------------------------------------------------------------------- /ui/public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/ui/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /docs/src/images/advanced_audio_player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamTV12345/PodFetch/HEAD/docs/src/images/advanced_audio_player.png -------------------------------------------------------------------------------- /migrations/postgres/2023-11-25-221424_cleaned_url_removal/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE episodes DROP COLUMN cleaned_url; -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-02-100903_create_podcast_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE podcasts; -------------------------------------------------------------------------------- /migrations/postgres/2023-12-28-114101_user_api_key/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE users DROP COLUMN api_key; -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-23-210248_likepodcasts/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcasts DROP COLUMN favored; -------------------------------------------------------------------------------- /src/adapters/file/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file_handle_wrapper; 2 | pub mod file_handler; 3 | pub mod local_file_handler; 4 | pub mod s3_file_handler; 5 | -------------------------------------------------------------------------------- /ui/src/models/PodcastSettingsUpdate.ts: -------------------------------------------------------------------------------- 1 | export type PodcastSettingsUpdate = { 2 | podcastId: number, 3 | episodeNumbering: boolean, 4 | } 5 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["SamTV12345"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "PodFetch Documentation" 7 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-07-201826_index_url_download/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP INDEX podcast_episode_url_index; -------------------------------------------------------------------------------- /migrations/postgres/2023-07-23-083222_playlist/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE playlists; 3 | DROP TABLE playlist_items; -------------------------------------------------------------------------------- /migrations/postgres/2025-10-25-082817_podcast_episode_chapters/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE podcast_episode_chapters; -------------------------------------------------------------------------------- /migrations/sqlite/2023-07-23-083222_playlist/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE playlists; 3 | DROP TABLE playlist_items; -------------------------------------------------------------------------------- /migrations/sqlite/2023-11-05-155720_episode_clean_url/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE episodes DROP COLUMN cleaned_url; -------------------------------------------------------------------------------- /migrations/sqlite/2025-01-05-150725_favorite_podcast_episodes/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE favorite_podcast_episodes; -------------------------------------------------------------------------------- /migrations/sqlite/2025-10-25-082817_podcast_episode_chapters/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE podcast_episode_chapters; -------------------------------------------------------------------------------- /src/models/opml_model.rs: -------------------------------------------------------------------------------- 1 | use utoipa::ToSchema; 2 | 3 | #[derive(Deserialize, ToSchema)] 4 | pub struct OpmlModel { 5 | pub content: String, 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/models/SysUser.ts: -------------------------------------------------------------------------------- 1 | export interface SysUser { 2 | id: string, 3 | group_id: string, 4 | name: string, 5 | groups: string[] 6 | } 7 | -------------------------------------------------------------------------------- /migrations/postgres/2023-11-05-155720_episode_clean_url/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE episodes DROP COLUMN cleaned_url; -------------------------------------------------------------------------------- /migrations/postgres/2025-01-05-150725_favorite_podcast_episodes/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE favorite_podcast_episodes; -------------------------------------------------------------------------------- /migrations/sqlite/2023-08-28-094450_settings_direct_paths/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE settings DROP COLUMN direct_paths; -------------------------------------------------------------------------------- /migrations/sqlite/2023-11-25-221424_cleaned_url_removal/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE episodes ADD COLUMN cleaned_url TEXT; -------------------------------------------------------------------------------- /ui/src/models/AddTypes.ts: -------------------------------------------------------------------------------- 1 | export enum AddTypes { 2 | ITUNES = "itunes", 3 | PODINDEX = "podindex", 4 | OPML = "opml", 5 | FEED = "feed" 6 | } 7 | -------------------------------------------------------------------------------- /migrations/postgres/2023-06-10-214350_podcast_episode_add_guid/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP COLUMN guid; -------------------------------------------------------------------------------- /migrations/postgres/2023-08-01-165632_delete-flag-episodes/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP COLUMN deleted; -------------------------------------------------------------------------------- /migrations/postgres/2023-08-28-094450_settings_direct_paths/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE settings DROP COLUMN direct_paths; -------------------------------------------------------------------------------- /migrations/postgres/2023-08-28-094450_settings_direct_paths/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE settings ADD COLUMN direct_paths BOOLEAN NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /migrations/postgres/2023-11-25-221424_cleaned_url_removal/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE episodes ADD COLUMN cleaned_url TEXT; -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-20-175610_only_favored_podcasts_filter/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE filters DROP COLUMN only_favored; -------------------------------------------------------------------------------- /migrations/sqlite/2023-06-10-214350_podcast_episode_add_guid/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP COLUMN guid; -------------------------------------------------------------------------------- /migrations/sqlite/2023-06-10-214350_podcast_episode_add_guid/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | 3 | ALTER TABLE podcast_episodes ADD COLUMN guid TEXT NOT NULL DEFAULT ''; -------------------------------------------------------------------------------- /migrations/sqlite/2023-08-01-165632_delete-flag-episodes/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP COLUMN deleted; -------------------------------------------------------------------------------- /migrations/sqlite/2023-08-01-165632_delete-flag-episodes/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcast_episodes ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /migrations/sqlite/2023-08-28-094450_settings_direct_paths/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE settings ADD COLUMN direct_paths BOOLEAN NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /src/utils/test_builder/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod device_test_builder; 2 | pub mod notification_test_builder; 3 | pub mod podcast_test_builder; 4 | pub mod user_test_builder; 5 | -------------------------------------------------------------------------------- /migrations/postgres/2023-03-02-100903_create_podcast_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP SCHEMA public CASCADE; 3 | CREATE SCHEMA public; -------------------------------------------------------------------------------- /migrations/postgres/2023-06-10-214350_podcast_episode_add_guid/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | 3 | ALTER TABLE podcast_episodes ADD COLUMN guid TEXT NOT NULL DEFAULT ''; -------------------------------------------------------------------------------- /migrations/postgres/2023-08-01-165632_delete-flag-episodes/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcast_episodes ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-27-085348_podcast_original/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcasts ADD COLUMN original_image_url VARCHAR(255) NOT NULL DEFAULT ''; -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-08-112006_config-initial-podcast-load/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE settings DROP COLUMN podcast_prefill; -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-08-112006_config-initial-podcast-load/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE settings ADD COLUMN podcast_prefill INTEGER DEFAULT 5 NOT NULL; -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-20-175610_only_favored_podcasts_filter/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE filters ADD COLUMN only_favored BOOLEAN NOT NULL DEFAULT TRUE; -------------------------------------------------------------------------------- /migrations/sqlite/2025-02-14-222819_unique_guid/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcasts DROP COLUMN guid; 3 | DROP INDEX unique_guid; -------------------------------------------------------------------------------- /ui/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | }, 7 | }); -------------------------------------------------------------------------------- /migrations/postgres/2025-02-14-222819_unique_guid/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcasts DROP COLUMN guid; 3 | DROP INDEX unique_guid; -------------------------------------------------------------------------------- /migrations/sqlite/2025-02-14-222819_unique_guid/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcasts ADD COLUMN guid TEXT; 3 | CREATE UNIQUE INDEX unique_guid ON podcasts (guid); -------------------------------------------------------------------------------- /migrations/postgres/2025-02-14-222819_unique_guid/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcasts ADD COLUMN guid TEXT; 3 | CREATE UNIQUE INDEX unique_guid ON podcasts (guid); -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-23-210248_likepodcasts/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | -- 1 = favored, 0 = not favored 3 | ALTER TABLE podcasts ADD COLUMN favored INT NOT NULL DEFAULT 0; -------------------------------------------------------------------------------- /migrations/sqlite/2023-12-28-114101_user_api_key/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE users DROP COLUMN api_key; 3 | DROP INDEX users_api_key_idx; -------------------------------------------------------------------------------- /src/gpodder/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod device; 3 | pub(crate) mod episodes; 4 | pub mod parametrization; 5 | pub(crate) mod session_middleware; 6 | pub mod subscription; 7 | -------------------------------------------------------------------------------- /ui/src/models/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: number, 3 | username: string, 4 | role: string, 5 | explicitConsent: boolean, 6 | createdAt: string 7 | } 8 | -------------------------------------------------------------------------------- /migrations/postgres/2023-12-28-114101_user_api_key/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE users ADD COLUMN api_key VARCHAR(255); 3 | CREATE INDEX users_api_key_idx ON users (api_key); -------------------------------------------------------------------------------- /ui/src/models/CPUModel.ts: -------------------------------------------------------------------------------- 1 | export interface CPUModel { 2 | cpu_usage: number, 3 | name: string, 4 | vendor_id: string, 5 | brand: string, 6 | frequency: number 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/models/Filter.ts: -------------------------------------------------------------------------------- 1 | export interface Filter { 2 | username: string 3 | title: string 4 | ascending: boolean 5 | filter?: string, 6 | onlyFavored: boolean 7 | } 8 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-12-28-114101_user_api_key/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE users ADD COLUMN api_key VARCHAR(255); 3 | 4 | CREATE INDEX users_api_key_idx ON users (api_key); -------------------------------------------------------------------------------- /migrations/postgres/2024-08-25-170551_episode_numbering/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcasts DROP COLUMN episode_numbering; 3 | DROP TABLE podcast_settings; -------------------------------------------------------------------------------- /migrations/sqlite/2023-11-05-155720_episode_clean_url/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE episodes ADD COLUMN cleaned_url TEXT NOT NULL DEFAULT ''; 3 | UPDATE episodes SET cleaned_url = episode; -------------------------------------------------------------------------------- /migrations/postgres/2023-11-05-155720_episode_clean_url/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE episodes ADD COLUMN cleaned_url TEXT NOT NULL DEFAULT ''; 3 | UPDATE episodes SET cleaned_url = episode; -------------------------------------------------------------------------------- /migrations/sqlite/2023-04-23-115251_gpodder_api/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE devices; 3 | DROP TABLE sessions; 4 | DROP TABLE subscriptions; 5 | DROP TABLE episodes; -------------------------------------------------------------------------------- /src/application/usecases/devices/edit_use_case.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::error::CustomError; 2 | 3 | pub trait EditUseCase { 4 | fn delete_by_username(username: &str) -> Result<(), CustomError>; 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/models/device/model.rs: -------------------------------------------------------------------------------- 1 | pub struct Device { 2 | pub id: Option, 3 | pub deviceid: String, 4 | pub kind: String, 5 | pub name: String, 6 | pub username: String, 7 | } 8 | -------------------------------------------------------------------------------- /src/models/dto_models.rs: -------------------------------------------------------------------------------- 1 | use utoipa::ToSchema; 2 | 3 | #[derive(Deserialize, Serialize, Debug, ToSchema)] 4 | pub struct PodcastFavorUpdateModel { 5 | pub id: i32, 6 | pub favored: bool, 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/models/Order.ts: -------------------------------------------------------------------------------- 1 | export enum Order { 2 | ASC="ASC", 3 | DESC="DESC" 4 | } 5 | 6 | export enum OrderCriteria { 7 | PUBLISHEDDATE="PUBLISHEDDATE", 8 | TITLE="TITLE" 9 | } 10 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-02-101007_create_podcast_episode_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP INDEX podcast_episodes_podcast_id_index; 3 | DROP TABLE IF EXISTS 'podcast_episodes'; -------------------------------------------------------------------------------- /ui/src/components/ui/ChartLoadingSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import {Skeleton} from "./skeleton"; 2 | 3 | export const ChartLoadingSkeleton = ()=>{ 4 | return 5 | } -------------------------------------------------------------------------------- /ui/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /ui/src/models/PodcastTags.tsx: -------------------------------------------------------------------------------- 1 | export type PodcastTags = { 2 | id: string, 3 | name: string, 4 | username: string, 5 | description?: string, 6 | created_at: string, 7 | color: string 8 | } -------------------------------------------------------------------------------- /migrations/sqlite/2023-04-16-085031_podcast_names/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcasts RENAME COLUMN directory_id TO directory; 3 | ALTER TABLE podcasts DROP COLUMN directory_name; -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-26-142313_settings/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE settings; 3 | ALTER TABLE podcast_episodes DROP COLUMN download_time; 4 | ALTER TABLE podcasts DROP COLUMN active; -------------------------------------------------------------------------------- /migrations/sqlite/2023-08-27-143946_podcast_episode_file_paths/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP file_episode_path; 3 | ALTER TABLE podcast_episodes DROP file_image_path; -------------------------------------------------------------------------------- /migrations/sqlite/2024-08-25-170551_episode_numbering/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP COLUMN episode_numbering_processed; 3 | DROP TABLE IF EXISTS podcast_settings; -------------------------------------------------------------------------------- /migrations/postgres/2023-08-27-143946_podcast_episode_file_paths/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes DROP file_episode_path; 3 | ALTER TABLE podcast_episodes DROP file_image_path; -------------------------------------------------------------------------------- /migrations/sqlite/2023-04-09-122459_user_management/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE users; 3 | DROP TABLE invites; 4 | ALTER TABLE podcasts ADD COLUMN favored BOOLEAN; 5 | DROP TABLE favorites; -------------------------------------------------------------------------------- /ui/src/models/Tags.ts: -------------------------------------------------------------------------------- 1 | interface Tags { 2 | id: number, 3 | name: string, 4 | color: string, 5 | username: string 6 | } 7 | 8 | export type TagCreate = { 9 | name: string, 10 | color: string 11 | } -------------------------------------------------------------------------------- /src/models/podcast_rssadd_model.rs: -------------------------------------------------------------------------------- 1 | use utoipa::ToSchema; 2 | 3 | #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] 4 | pub struct PodcastRSSAddModel { 5 | #[serde(rename = "rssFeedUrl")] 6 | pub rss_feed_url: String, 7 | } 8 | -------------------------------------------------------------------------------- /src/gpodder/device/dto/device_post.rs: -------------------------------------------------------------------------------- 1 | use utoipa::ToSchema; 2 | 3 | #[derive(Serialize, Deserialize, Clone, ToSchema)] 4 | pub struct DevicePost { 5 | pub caption: String, 6 | #[serde(rename = "type")] 7 | pub kind: String, 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/utils/decodingUtilities.ts: -------------------------------------------------------------------------------- 1 | export const decodeHTMLEntities = (html: string): string => { 2 | const textArea = document.createElement('textarea'); 3 | textArea.innerHTML = html; 4 | textArea.remove() 5 | return textArea.value; 6 | } -------------------------------------------------------------------------------- /migrations/mysql/2023-03-02-100903_create_podcast_table/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE episodes; 3 | DROP TABLE episodes; 4 | DROP TABLE episodes; 5 | DROP TABLE episodes; 6 | DROP TABLE episodes; 7 | DROP TABLE episodes; -------------------------------------------------------------------------------- /src/application/usecases/devices/create_use_case.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::device::model::Device; 2 | use crate::utils::error::CustomError; 3 | 4 | pub trait CreateUseCase { 5 | fn create(device_to_safe: Device) -> Result; 6 | } 7 | -------------------------------------------------------------------------------- /src/application/usecases/devices/query_use_case.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::device::model::Device; 2 | use crate::utils::error::CustomError; 3 | 4 | pub trait QueryUseCase { 5 | fn query_by_username(username: &str) -> Result, CustomError>; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/environment_variables.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | pub fn is_env_var_present_and_true(env_var: &str) -> bool { 4 | match env::var(env_var) { 5 | Ok(val) => val == "true" || val == "1" || val == "yes", 6 | Err(_) => false, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/models/PodcastWatchedModel.tsx: -------------------------------------------------------------------------------- 1 | export interface PodcastWatchedModel { 2 | id: number, 3 | podcastId: number, 4 | episodeId: number, 5 | watchedTime: number, 6 | date: String 7 | total: number 8 | position: number 9 | } 10 | -------------------------------------------------------------------------------- /diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see https://diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/adapters/persistence/dbconfig/schemas/sqlite/schema.rs" 6 | 7 | [migrations_directory] 8 | dir = "migrations" 9 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-07-201826_index_url_download/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE INDEX podcast_episode_url_index ON podcast_episodes (url); 3 | --- N->Not download D->Downloaded P->Pending 4 | ALTER TABLE podcast_episodes ADD COLUMN status CHAR(1) NOT NULL DEFAULT 'N'; -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-07-193017_persist-filter/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE filters( 3 | username TEXT PRIMARY KEY NOT NULL, 4 | title TEXT, 5 | ascending BOOLEAN DEFAULT FALSE NOT NULL, 6 | filter TEXT CHECK(filter IN ('PublishedDate', 'Title')) 7 | ) -------------------------------------------------------------------------------- /ui/src/models/EpisodesWithOptionalTimeline.ts: -------------------------------------------------------------------------------- 1 | import {components} from "../../schema"; 2 | 3 | export interface EpisodesWithOptionalTimeline { 4 | podcastEpisode: components["schemas"]["PodcastEpisodeDto"], 5 | podcastHistoryItem?: components["schemas"]["EpisodeDto"] 6 | } 7 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-08-210254_notifications/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE notifications ( 3 | id integer primary key not null, 4 | type_of_message TEXT NOT NULL, 5 | message TEXT NOT NULL, 6 | created_at TEXT NOT NULL, 7 | status TEXT NOT NULL 8 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | *.db 3 | .idea 4 | podcasts 5 | static/* 6 | .env 7 | *.iml 8 | 9 | !static/default.jpg 10 | podcast.db-shm 11 | podcast.db-wal 12 | 13 | static/*.jpg 14 | local-configs 15 | .terraform.lock.hcl 16 | .terraform 17 | terraform.tfstate 18 | terraform.tfstate.backup -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | group "default" { 2 | targets = ["podfetch"] 3 | } 4 | 5 | target "podfetch" { 6 | dockerfile = "Dockerfile_cross" 7 | tags= ["samuel19982/podfetch:dev"] 8 | platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7"] 9 | args = { 10 | CACHEBUST = "1" 11 | } 12 | } -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | }, 6 | "composite": true, 7 | "module": "ESNext", 8 | "moduleResolution": "Node", 9 | "allowSyntheticDefaultImports": true 10 | }, 11 | "include": ["vite.config.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /migrations/postgres/2023-07-08-200149_enlarge-url-columns/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE episodes ALTER COLUMN episode TYPE TEXT; 3 | ALTER TABLE episodes ALTER COLUMN guid TYPE TEXT; 4 | ALTER TABLE podcasts ALTER COLUMN original_image_url TYPE TEXT; 5 | ALTER TABLE podcasts ALTER COLUMN directory_name TYPE TEXT; -------------------------------------------------------------------------------- /ui/src/components/ui/LoadingPodcastCard.tsx: -------------------------------------------------------------------------------- 1 | import {Skeleton} from "./skeleton"; 2 | 3 | export const LoadingPodcastCard = ()=>{ 4 | return
5 | 6 | 7 |
8 | } -------------------------------------------------------------------------------- /migrations/sqlite/2025-01-10-163832_download_location/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes ADD COLUMN local_image_url TEXT; 3 | ALTER TABLE podcast_episodes ADD COLUMN local_url TEXT; 4 | 5 | ALTER TABLE podcasts DROP COLUMN download_location; 6 | ALTER TABLE podcast_episodes DROP COLUMN download_location; -------------------------------------------------------------------------------- /migrations/postgres/2023-08-27-143946_podcast_episode_file_paths/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcast_episodes ADD COLUMN file_episode_path TEXT; 3 | ALTER TABLE podcast_episodes ADD COLUMN file_image_path TEXT; 4 | 5 | UPDATE podcast_episodes SET file_episode_path = local_url; 6 | UPDATE podcast_episodes SET file_image_path = local_image_url; -------------------------------------------------------------------------------- /migrations/postgres/2025-01-10-163832_download_location/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcast_episodes ADD COLUMN local_image_url TEXT; 3 | ALTER TABLE podcast_episodes ADD COLUMN local_url TEXT; 4 | 5 | ALTER TABLE podcasts DROP COLUMN download_location; 6 | ALTER TABLE podcast_episodes DROP COLUMN download_location; -------------------------------------------------------------------------------- /migrations/sqlite/2023-08-27-143946_podcast_episode_file_paths/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcast_episodes ADD COLUMN file_episode_path TEXT; 3 | ALTER TABLE podcast_episodes ADD COLUMN file_image_path TEXT; 4 | 5 | UPDATE podcast_episodes SET file_episode_path = local_url; 6 | UPDATE podcast_episodes SET file_image_path = local_image_url; -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/start-podfetch/variables.tf: -------------------------------------------------------------------------------- 1 | variable "server_url" { 2 | description = "The URL of the podfetch server" 3 | } 4 | 5 | variable "podcast_dir" { 6 | description = "The directory where podcasts are stored" 7 | } 8 | 9 | 10 | variable "db_dir" { 11 | description = "The directory where the podfetch database is stored" 12 | } -------------------------------------------------------------------------------- /ui/public/microphone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/models/Episode.ts: -------------------------------------------------------------------------------- 1 | export interface Episode { 2 | action: string, 3 | clean_url: string, 4 | device: string, 5 | episode: string, 6 | guid: string, 7 | id: number, 8 | podcast: string, 9 | position: number, 10 | started: number, 11 | timestamp: string, 12 | total: number, 13 | username: string, 14 | } 15 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/start-db/variables.tf: -------------------------------------------------------------------------------- 1 | variable "db_user" { 2 | description = "The database user" 3 | } 4 | 5 | variable "db_password" { 6 | description = "The database password" 7 | } 8 | 9 | variable "db_name" { 10 | description = "The database name" 11 | } 12 | 13 | variable "db_dir" { 14 | description = "The database directory" 15 | } -------------------------------------------------------------------------------- /ui/src/components/Heading3.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type Heading3Props = { 4 | children: string, 5 | className?: string 6 | } 7 | 8 | export const Heading3: FC = ({ children, className = '' }) => { 9 | return ( 10 |

{children}

11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /migrations/postgres/2023-07-08-200149_enlarge-url-columns/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE episodes ALTER COLUMN episode TYPE varchar(255); 3 | ALTER TABLE episodes ALTER COLUMN guid TYPE varchar(255); 4 | ALTER TABLE podcasts ALTER COLUMN original_image_url TYPE varchar(255); 5 | ALTER TABLE podcasts ALTER COLUMN directory_name TYPE varchar(255); -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-25-104928_podcast_extra/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE podcasts DROP COLUMN summary; 3 | ALTER TABLE podcasts DROP COLUMN language; 4 | ALTER TABLE podcasts DROP COLUMN explicit; 5 | ALTER TABLE podcasts DROP COLUMN keywords; 6 | ALTER TABLE podcasts DROP COLUMN last_build_date; 7 | ALTER TABLE podcasts DROP COLUMN author; -------------------------------------------------------------------------------- /ui/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../../lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/samples/traefik.toml: -------------------------------------------------------------------------------- 1 | [global] 2 | checkNewVersion = true 3 | sendAnonymousUsage = true 4 | 5 | [entryPoints] 6 | [entryPoints.web] 7 | address = ":80" 8 | 9 | [entryPoints.websecure] 10 | address = ":443" 11 | 12 | 13 | [log] 14 | level = "DEBUG" 15 | 16 | [accessLog] 17 | filePath = "/var/log/traefik/access.log" 18 | 19 | [providers.docker] -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/samples/traefik.toml: -------------------------------------------------------------------------------- 1 | [global] 2 | checkNewVersion = true 3 | sendAnonymousUsage = true 4 | 5 | [entryPoints] 6 | [entryPoints.web] 7 | address = ":80" 8 | 9 | [entryPoints.websecure] 10 | address = ":443" 11 | 12 | 13 | [log] 14 | level = "DEBUG" 15 | 16 | [accessLog] 17 | filePath = "/var/log/traefik/access.log" 18 | 19 | [providers.docker] -------------------------------------------------------------------------------- /ui/src/components/Heading1.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type Heading1Props = { 4 | children: string, 5 | className?: string 6 | } 7 | 8 | export const Heading1: FC = ({ children, className = '' }) => { 9 | return ( 10 |

{children}

11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/Heading2.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type Heading2Props = { 4 | children: string, 5 | className?: string 6 | } 7 | 8 | export const Heading2: FC = ({ children, className = '' }) => { 9 | return ( 10 |

{children}

11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/icons/MaginifyingGlassIcon.tsx: -------------------------------------------------------------------------------- 1 | export const MaginifyingGlassIcon = ()=>{ 2 | return 3 | 4 | 5 | 6 | } -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-14-184857_podcast-name-adjustment/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE settings DROP COLUMN replace_invalid_characters; 3 | ALTER TABLE settings DROP COLUMN use_existing_filename; 4 | ALTER TABLE settings DROP COLUMN replacement_strategy; 5 | ALTER TABLE settings DROP COLUMN episode_format; 6 | ALTER TABLE settings DROP COLUMN podcast_format; -------------------------------------------------------------------------------- /ui/src/hooks/useOnMount.ts: -------------------------------------------------------------------------------- 1 | import {useLayoutEffect, useRef} from 'react'; 2 | 3 | function useOnMount(callback: any) { 4 | const hasRunRef = useRef(false); 5 | 6 | useLayoutEffect(() => { 7 | if (!hasRunRef.current) { 8 | callback(); 9 | hasRunRef.current = true; 10 | } 11 | }, [callback]); 12 | } 13 | 14 | export default useOnMount; 15 | -------------------------------------------------------------------------------- /ui/src/icons/InfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | 3 | type InfoIconProps = { 4 | className?: string 5 | onClick?: () => void 6 | } 7 | export const InfoIcon:FC = ({onClick, className}) => { 8 | return onClick ? onClick() :''}/> 9 | } 10 | -------------------------------------------------------------------------------- /src/application/repositories/device_repository.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::device::model::Device; 2 | use crate::utils::error::CustomError; 3 | 4 | pub trait DeviceRepository { 5 | fn create(device: Device) -> Result; 6 | fn get_devices_of_user(username: &str) -> Result, CustomError>; 7 | fn delete_by_username(username: &str) -> Result<(), CustomError>; 8 | } 9 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-25-104928_podcast_extra/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcasts ADD COLUMN summary TEXT NULL; 3 | ALTER TABLE podcasts ADD COLUMN language TEXT NULL; 4 | ALTER TABLE podcasts ADD COLUMN explicit TEXT NULL; 5 | ALTER TABLE podcasts ADD COLUMN keywords TEXT NULL; 6 | ALTER TABLE podcasts ADD COLUMN last_build_date TEXT NULL; 7 | ALTER TABLE podcasts ADD COLUMN author TEXT NULL; 8 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | vite-local.ts 26 | dev-dist/ 27 | -------------------------------------------------------------------------------- /src/models/gpodder_available_podcasts.rs: -------------------------------------------------------------------------------- 1 | use diesel::sql_types::Text; 2 | use diesel::{Queryable, QueryableByName}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Serialize, QueryableByName, Queryable, ToSchema)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct GPodderAvailablePodcasts { 8 | #[diesel(sql_type = Text)] 9 | pub device: String, 10 | #[diesel(sql_type = Text)] 11 | pub podcast: String, 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod append_to_header; 2 | pub mod auth; 3 | pub mod do_retry; 4 | pub mod environment_variables; 5 | pub mod error; 6 | pub mod file_extension_determination; 7 | pub mod file_name_replacement; 8 | pub mod gpodder_trimmer; 9 | pub mod http_client; 10 | pub mod podcast_builder; 11 | pub mod podcast_key_checker; 12 | pub mod reqwest_client; 13 | pub(crate) mod rss_feed_parser; 14 | pub mod test_builder; 15 | pub mod time; 16 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod download_service; 2 | pub mod environment_service; 3 | pub(crate) mod file_service; 4 | pub mod logging_service; 5 | pub mod notification_service; 6 | pub mod path_service; 7 | pub mod podcast_chapter; 8 | pub mod podcast_episode_service; 9 | pub mod rust_service; 10 | pub mod settings_service; 11 | pub mod subscription; 12 | pub mod telegram_api; 13 | pub mod user_management_service; 14 | pub mod websocket_service; 15 | -------------------------------------------------------------------------------- /ui/src/components/OIDCButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import { CustomButtonPrimary } from './CustomButtonPrimary' 3 | 4 | export const OIDCButton = () => { 5 | const { t } = useTranslation() 6 | 7 | return ( 8 | { 9 | window.location.href = "../" 10 | }}>{t('oidc-login')} 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/models/Setting.tsx: -------------------------------------------------------------------------------- 1 | export type Setting = { 2 | id: number, 3 | autoDownload: boolean, 4 | autoUpdate: boolean, 5 | autoCleanup: boolean, 6 | autoCleanupDays: number, 7 | podcastPrefill: number, 8 | useExistingFilename: boolean, 9 | replaceInvalidCharacters: boolean, 10 | replacementStrategy: string, 11 | episodeFormat: string, 12 | podcastFormat: string, 13 | directPaths: boolean, 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/models/TimeLineModel.ts: -------------------------------------------------------------------------------- 1 | import {Podcast, PodcastEpisode} from "../store/CommonSlice"; 2 | import {PodcastWatchedModel} from "./PodcastWatchedModel"; 3 | import {Episode} from "./Episode"; 4 | 5 | export type TimelineHATEOASModel = { 6 | data: TimeLineModel[], 7 | totalElements: number 8 | } 9 | 10 | export type TimeLineModel = { 11 | podcast: Podcast, 12 | podcast_episode: PodcastEpisode, 13 | history: Episode 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/models/PodcastWatchedEpisodeModel.ts: -------------------------------------------------------------------------------- 1 | import {Podcast, PodcastEpisode} from "../store/CommonSlice"; 2 | 3 | export interface PodcastWatchedEpisodeModel { 4 | id: number, 5 | podcastId: number, 6 | episodeId: string, 7 | url: string, 8 | name:string, 9 | date: string, 10 | imageUrl: string, 11 | watchedTime: number, 12 | totalTime: number, 13 | podcastEpisode: PodcastEpisode, 14 | podcast: Podcast 15 | } 16 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-02-100903_create_podcast_table/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | create table if not exists podcasts ( 3 | id integer primary key not null, 4 | name text not null unique, 5 | directory text not null, 6 | rssfeed text not null, 7 | image_url text not null) -------------------------------------------------------------------------------- /ui/src/icons/LanguageIcon.tsx: -------------------------------------------------------------------------------- 1 | export const LanguageIcon = ()=>{ 2 | return 3 | 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/controllers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controller_utils; 2 | pub mod file_hosting; 3 | pub mod manifest_controller; 4 | pub mod notification_controller; 5 | pub mod playlist_controller; 6 | pub mod podcast_controller; 7 | pub mod podcast_episode_controller; 8 | pub mod server; 9 | pub mod settings_controller; 10 | pub mod sys_info_controller; 11 | pub mod tags_controller; 12 | pub mod user_controller; 13 | pub mod watch_time_controller; 14 | pub mod websocket_controller; 15 | -------------------------------------------------------------------------------- /src/models/search_type.rs: -------------------------------------------------------------------------------- 1 | pub enum SearchType { 2 | ITunes, 3 | Podindex, 4 | } 5 | 6 | impl TryFrom for SearchType { 7 | type Error = (); 8 | 9 | fn try_from(v: i32) -> Result { 10 | match v { 11 | x if x == SearchType::Podindex as i32 => Ok(SearchType::Podindex), 12 | x if x == SearchType::ITunes as i32 => Ok(SearchType::ITunes), 13 | _ => Err(()), 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | podfetch: 4 | image: samuel19982/podfetch:latest 5 | user: ${UID:-1000}:${GID:-1000} 6 | ports: 7 | - "80:8000" 8 | volumes: 9 | - podfetch-podcasts:/app/podcasts 10 | - podfetch-db:/app/db 11 | environment: 12 | - POLLING_INTERVAL=60 13 | - SERVER_URL=http://localhost:80 14 | - DATABASE_URL=sqlite:///app/db/podcast.db 15 | 16 | volumes: 17 | podfetch-podcasts: 18 | podfetch-db: -------------------------------------------------------------------------------- /docs/src/rss_feed.md: -------------------------------------------------------------------------------- 1 | # RSS Feed 2 | 3 | You can use the RSS feed to get the latest updates from the site. The feed is available at the following URL: 4 | 5 | ``` 6 | http:///rss 7 | ``` 8 | 9 | You can also control the output of the feed by using the following parameter: 10 | - top: The number of items to return 11 | 12 | So if you want to get the latest 5 items from the feed from each podcast, 13 | you can use the following URL: 14 | 15 | ``` 16 | http:///rss?top=5 17 | ``` -------------------------------------------------------------------------------- /ui/src/pages/EpisodeSearchPage.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import { Heading1 } from '../components/Heading1' 3 | import { EpisodeSearch } from '../components/EpisodeSearch' 4 | 5 | export const EpisodeSearchPage = () => { 6 | const { t } = useTranslation() 7 | 8 | return ( 9 | <> 10 | {t('search-episodes')} 11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import type {components} from "../../schema"; 2 | 3 | export const getConfigFromHtmlFile = (): components['schemas']['ConfigModel'] | undefined => { 4 | const config = document.getElementById('config') 5 | 6 | const dataJson = config?.getAttribute('data-config') 7 | 8 | 9 | let configObj: components['schemas']['ConfigModel'] | undefined 10 | 11 | 12 | if (dataJson) { 13 | configObj = JSON.parse(dataJson) 14 | } 15 | return configObj 16 | } 17 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/prepare-traefik/variables.tf: -------------------------------------------------------------------------------- 1 | variable "traefik_toml_location" { 2 | description = "The location of the traefik.toml file" 3 | } 4 | 5 | variable "traefik_acme_location" { 6 | description = "The location of the acme.json file" 7 | } 8 | 9 | 10 | variable "traefik_access_log_location" { 11 | description = "The location of the access.log file" 12 | } 13 | 14 | variable "traefik_dynamic_conf_location" { 15 | description = "The location of the dynamic configuration files" 16 | } -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/prepare-traefik/variables.tf: -------------------------------------------------------------------------------- 1 | variable "traefik_toml_location" { 2 | description = "The location of the traefik.toml file" 3 | } 4 | 5 | variable "traefik_acme_location" { 6 | description = "The location of the acme.json file" 7 | } 8 | 9 | 10 | variable "traefik_access_log_location" { 11 | description = "The location of the access.log file" 12 | } 13 | 14 | variable "traefik_dynamic_conf_location" { 15 | description = "The location of the dynamic configuration files" 16 | } -------------------------------------------------------------------------------- /ui/src/utils/ErrorDefinition.ts: -------------------------------------------------------------------------------- 1 | import {t} from "i18next"; 2 | 3 | export class APIError extends Error { 4 | details: { 5 | errorCode: string; 6 | arguments?: Record; 7 | } 8 | 9 | constructor(details: { errorCode: string; arguments?: Record; } = {errorCode: "UNKNOWN_ERROR"}) { 10 | super(); 11 | this.details = details; 12 | } 13 | 14 | toString() { 15 | return t(this.details.errorCode, this.details.arguments); 16 | } 17 | } -------------------------------------------------------------------------------- /migrations/postgres/2023-07-23-083222_playlist/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE playlists ( 3 | id TEXT NOT NULL PRIMARY KEY, 4 | name TEXT NOT NULL, 5 | user_id INTEGER NOT NULL 6 | ); 7 | 8 | 9 | CREATE TABLE playlist_items ( 10 | playlist_id TEXT NOT NULL, 11 | episode INTEGER NOT NULL, 12 | position INTEGER NOT NULL, 13 | FOREIGN KEY (playlist_id) REFERENCES playlists(id), 14 | FOREIGN KEY (episode) REFERENCES podcast_episodes(id), 15 | PRIMARY KEY (playlist_id, episode) 16 | ); -------------------------------------------------------------------------------- /migrations/postgres/2025-01-10-163832_download_location/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcast_episodes DROP COLUMN local_image_url; 3 | ALTER TABLE podcast_episodes DROP COLUMN local_url; 4 | 5 | 6 | ALTER TABLE podcasts ADD COLUMN download_location TEXT; 7 | ALTER TABLE podcast_episodes ADD COLUMN download_location TEXT; 8 | 9 | UPDATE podcasts SET download_location = 'Local'; 10 | UPDATE podcast_episodes SET download_location = 'Local' WHERE status = 'D'; 11 | 12 | ALTER TABLE podcast_episodes DROP COLUMN status; -------------------------------------------------------------------------------- /migrations/sqlite/2023-07-23-083222_playlist/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE playlists ( 3 | id TEXT NOT NULL PRIMARY KEY, 4 | name TEXT NOT NULL, 5 | user_id INTEGER NOT NULL 6 | ); 7 | 8 | 9 | CREATE TABLE playlist_items ( 10 | playlist_id TEXT NOT NULL, 11 | episode INTEGER NOT NULL, 12 | position INTEGER NOT NULL, 13 | FOREIGN KEY (playlist_id) REFERENCES playlists(id), 14 | FOREIGN KEY (episode) REFERENCES podcast_episodes(id), 15 | PRIMARY KEY (playlist_id, episode) 16 | ); -------------------------------------------------------------------------------- /migrations/sqlite/2025-01-10-163832_download_location/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcast_episodes DROP COLUMN local_image_url; 3 | ALTER TABLE podcast_episodes DROP COLUMN local_url; 4 | 5 | 6 | ALTER TABLE podcasts ADD COLUMN download_location TEXT; 7 | ALTER TABLE podcast_episodes ADD COLUMN download_location TEXT; 8 | 9 | UPDATE podcasts SET download_location = 'Local'; 10 | UPDATE podcast_episodes SET download_location = 'Local' WHERE status = 'D'; 11 | 12 | ALTER TABLE podcast_episodes DROP COLUMN status; -------------------------------------------------------------------------------- /ui/src/models/PodcastSetting.ts: -------------------------------------------------------------------------------- 1 | export type PodcastSetting = { 2 | podcastId: number, 3 | episodeNumbering: boolean, 4 | autoDownload: boolean, 5 | autoUpdate: boolean, 6 | autoCleanup: boolean, 7 | autoCleanupDays: number, 8 | replaceInvalidCharacters: boolean, 9 | useExistingFilename: boolean, 10 | replacementStrategy: string, 11 | episodeFormat: string, 12 | podcastFormat: string, 13 | directPaths: boolean, 14 | activated: boolean, 15 | podcastPrefill: number, 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/utils/audioPlayer.ts: -------------------------------------------------------------------------------- 1 | export const getAudioPlayer = () => { 2 | return document.getElementById('audio-player') as HTMLAudioElement 3 | } 4 | 5 | 6 | export const startAudioPlayer = async (audioUrl: string, position: number)=>{ 7 | console.log("Starting audio " + audioUrl + " at position " + position) 8 | const audioPlayer = getAudioPlayer() 9 | audioPlayer.pause() 10 | audioPlayer.src = audioUrl 11 | audioPlayer.currentTime = position 12 | audioPlayer.load() 13 | await audioPlayer.play() 14 | } -------------------------------------------------------------------------------- /src/models/color.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::fmt::{Display, Formatter}; 3 | use utoipa::ToSchema; 4 | 5 | #[derive(Debug, Serialize, Deserialize, ToSchema)] 6 | pub enum Color { 7 | Red, 8 | Green, 9 | Blue, 10 | } 11 | 12 | impl Display for Color { 13 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 14 | match self { 15 | Color::Red => write!(f, "Red"), 16 | Color::Green => write!(f, "Green"), 17 | Color::Blue => write!(f, "Blue"), 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/service/notification_service.rs: -------------------------------------------------------------------------------- 1 | use crate::models::notification::Notification; 2 | use crate::utils::error::CustomError; 3 | 4 | pub struct NotificationService {} 5 | 6 | impl NotificationService { 7 | pub fn get_unread_notifications() -> Result, CustomError> { 8 | Notification::get_unread_notifications() 9 | } 10 | 11 | pub fn update_status_of_notification(id: i32, status: &str) -> Result<(), CustomError> { 12 | Notification::update_status_of_notification(id, status) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /migrations/postgres/2025-01-05-150725_favorite_podcast_episodes/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE favorite_podcast_episodes ( 3 | username TEXT NOT NULL, 4 | episode_id INT NOT NULL, 5 | favorite BOOLEAN NOT NULL DEFAULT FALSE, 6 | PRIMARY KEY (username, episode_id), 7 | FOREIGN KEY (episode_id) REFERENCES podcast_episodes(id) 8 | ); -------------------------------------------------------------------------------- /migrations/sqlite/2025-01-05-150725_favorite_podcast_episodes/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE favorite_podcast_episodes ( 3 | username TEXT NOT NULL, 4 | episode_id INT NOT NULL, 5 | favorite BOOLEAN NOT NULL DEFAULT FALSE, 6 | PRIMARY KEY (username, episode_id), 7 | FOREIGN KEY (episode_id) REFERENCES podcast_episodes(id) 8 | ); -------------------------------------------------------------------------------- /ui/src/components/ui/LoadingSkeletonDD.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | import {Skeleton} from "./skeleton"; 3 | 4 | type LoadingSkeletonProps = { 5 | loading?: boolean, 6 | text: string| undefined|number 7 | } 8 | 9 | export const LoadingSkeletonDD: FC = ({ 10 | loading, 11 | text 12 | }) => { 13 | return ( 14 |
{loading == true ? : text}
15 | ) 16 | } -------------------------------------------------------------------------------- /ui/test/HtmlCodeReplacement.test.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect} from "vitest"; 2 | import {decodeHTMLEntities} from "../src/utils/decodingUtilities"; 3 | 4 | describe("Html code replacement", () => { 5 | it("should replace the html code", () => { 6 | const html = "
Pourquoi les ministres n'ont plus le droit d'utilisier
"; 7 | const replacedHtml = decodeHTMLEntities(html); 8 | expect(replacedHtml).toBe("
Pourquoi les ministres n'ont plus le droit d'utilisier
"); 9 | }); 10 | }) 11 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/start-podfetch/variables.tf: -------------------------------------------------------------------------------- 1 | variable "server_url" { 2 | default = "http://podfetch.example.com" 3 | description = "The URL of the podfetch server" 4 | } 5 | 6 | variable "podcast_dir" { 7 | description = "The directory where podcasts are stored" 8 | } 9 | 10 | 11 | variable "db_user" { 12 | description = "The database user" 13 | } 14 | 15 | variable "db_password" { 16 | description = "The database password" 17 | } 18 | 19 | variable "db_name" { 20 | description = "The database name" 21 | } 22 | -------------------------------------------------------------------------------- /ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | }, 21 | "registries": {} 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/icons/RefreshIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | 3 | type RefreshIconProps = { 4 | onClick: ()=>void 5 | } 6 | 7 | export const RefreshIcon:FC = ({onClick}) => { 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/do_retry.rs: -------------------------------------------------------------------------------- 1 | use std::thread::sleep; 2 | use std::time::Duration; 3 | 4 | pub fn do_retry(mut func: F) -> Result 5 | where 6 | F: FnMut() -> Result, 7 | { 8 | let mut tries = 0; 9 | 10 | loop { 11 | match func() { 12 | ok @ Ok(_) => return ok, 13 | err @ Err(_) => { 14 | tries += 1; 15 | 16 | if tries >= 10 { 17 | return err; 18 | } 19 | sleep(Duration::from_millis(500)) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/adapters/api/models/device/device_create.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::device::model::Device; 2 | 3 | pub struct DeviceCreate { 4 | pub(crate) id: String, 5 | pub(crate) caption: String, 6 | pub(crate) type_: String, 7 | pub(crate) username: String, 8 | } 9 | 10 | impl From for Device { 11 | fn from(val: DeviceCreate) -> Self { 12 | Device { 13 | id: None, 14 | deviceid: val.id, 15 | kind: val.type_, 16 | name: val.caption, 17 | username: val.username, 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-26-142313_settings/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE settings ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | auto_download BOOLEAN NOT NULL DEFAULT TRUE, 5 | auto_update BOOLEAN NOT NULL DEFAULT TRUE, 6 | auto_cleanup BOOLEAN NOT NULL DEFAULT FALSE, 7 | auto_cleanup_days INTEGER NOT NULL DEFAULT -1 8 | ); 9 | 10 | ALTER TABLE podcast_episodes ADD COLUMN download_time DATETIME NULL; 11 | ALTER TABLE podcasts ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE; 12 | 13 | UPDATE podcast_episodes SET download_time = datetime('now') WHERE status='D'; -------------------------------------------------------------------------------- /migrations/sqlite/2025-10-25-082817_podcast_episode_chapters/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE podcast_episode_chapters ( 3 | id TEXT PRIMARY KEY NOT NULL, 4 | episode_id INTEGER NOT NULL REFERENCES podcast_episodes(id) ON DELETE CASCADE, 5 | title TEXT NOT NULL, 6 | start_time INTEGER NOT NULL, 7 | end_time INTEGER NOT NULL, 8 | href TEXT, 9 | image TEXT, 10 | created_at TIMESTAMP NOT NULL, 11 | updated_at TIMESTAMP NOT NULL 12 | ); 13 | 14 | CREATE UNIQUE INDEX uq_podcast_episode_chapters_episode_start 15 | ON podcast_episode_chapters (episode_id, start_time); -------------------------------------------------------------------------------- /ui/src/utils/navigationUtils.ts: -------------------------------------------------------------------------------- 1 | import {client} from "./http"; 2 | 3 | const wsEndpoint = "ws" 4 | 5 | export const configWSUrl = (url: string) => { 6 | if (url.startsWith("http")) { 7 | return url.replace("http", "ws") + wsEndpoint 8 | } 9 | return url.replace("https", "wss") + wsEndpoint 10 | } 11 | export const logCurrentPlaybackTime = (episodeId: string, timeInSeconds: number) => { 12 | client.POST("/api/v1/podcasts/episode", { 13 | body: { 14 | podcastEpisodeId: episodeId, 15 | time: Number(timeInSeconds.toFixed(0)) 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/store/ModalSlice.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | // Define a type for the slice state 4 | import {create} from "zustand"; 5 | 6 | interface ModalProps { 7 | openModal:boolean, 8 | openAddModal: boolean, 9 | setOpenModal: (openModal: boolean) => void, 10 | setOpenAddModal: (openAddModal: boolean) => void 11 | } 12 | 13 | 14 | const useModal = create((set, get) => ({ 15 | openModal: false, 16 | openAddModal: false, 17 | setOpenModal: (openModal: boolean) => set({openModal}), 18 | setOpenAddModal: (openAddModal: boolean) => set({openAddModal}) 19 | })) 20 | 21 | 22 | export default useModal 23 | -------------------------------------------------------------------------------- /ui/src/utils/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import {DependencyList, EffectCallback, useMemo, useRef} from "react"; 2 | import {useAnimationFrame} from "./AnimationFrameHook"; 3 | 4 | const defaultDeps: DependencyList = [] 5 | 6 | export const useDebounce = ( 7 | fn:EffectCallback, 8 | wait = 0, 9 | deps = defaultDeps 10 | ):void => { 11 | const isFirstRender = useRef(true) 12 | const render = useAnimationFrame(fn, wait) 13 | 14 | useMemo(()=>{ 15 | if(isFirstRender.current){ 16 | isFirstRender.current = false 17 | return 18 | } 19 | 20 | render() 21 | }, deps) 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/I18n.md: -------------------------------------------------------------------------------- 1 | ## Internationalization 2 | 3 | Podfetch is available in multiple languages. 4 | If you want to add a new language you can do so by adding a new file to the`i18n` folder and adding the translations 5 | to the file. The file should be named after the [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) 6 | language code. For example `en.json` for English or `de.json` for German. 7 | 8 | If you want to add a new language, please take a look at this [file](https://github.com/SamTV12345/PodFetch/blob/main/ui/src/language/json/en.json) to see which translations are required. 9 | You only need to add the translations for the values. -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](./Introduction.md) 4 | - [S3](./S3.md) 5 | - [Installation](./Installation.md) 6 | - [Authorization](./AUTH.md) 7 | - [UI Walkthrough](./UIWalkthrough.md) 8 | - [Hosting](./HOSTING.md) 9 | - [Translations](./I18n.md) 10 | - [RSS Feed](./rss_feed.md) 11 | - [Podindex Integration](./podindex.md) 12 | - [CLI usage](./CLI.md) 13 | - [Contributing](./Contributing.md) 14 | 15 | # Tutorials 16 | 17 | - [Setting up basic auth](./tutorials/BasicAuth.md) 18 | - [Setting up OIDC](./tutorials/OIDC.md) 19 | - [Adding GPodder support](./tutorials/GPodder.md) 20 | 21 | 22 | # FAQ 23 | 24 | - [Pitfalls](./FAQ.md) -------------------------------------------------------------------------------- /ui/src/models/PodcastAddModel.ts: -------------------------------------------------------------------------------- 1 | export type PodcastAddModel = { 2 | artworkUrl600: string, 3 | artistName: string, 4 | collectionName: string, 5 | trackId: number 6 | } 7 | 8 | export type GeneralModel = { 9 | resultCount: number, 10 | results: PodcastAddModel[] 11 | } 12 | 13 | 14 | export type AgnosticPodcastDataModel = { 15 | imageUrl: string, 16 | title: string, 17 | artist: string, 18 | id: number 19 | } 20 | 21 | 22 | export type PodIndexModel = { 23 | feeds: [{ 24 | artwork: string, 25 | title: string, 26 | id: number, 27 | author: string 28 | }] 29 | } 30 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-14-184857_podcast-name-adjustment/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE settings ADD COLUMN replace_invalid_characters BOOLEAN DEFAULT TRUE NOT NULL; 3 | ALTER TABLE settings ADD COLUMN use_existing_filename BOOLEAN DEFAULT FALSE NOT NULL; 4 | ALTER TABLE settings ADD COLUMN replacement_strategy TEXT CHECK(replacement_strategy IN 5 | ('replace-with-dash-and-underscore', 'remove', 'replace-with-dash')) NOT NULL DEFAULT 6 | 'replace-with-dash-and-underscore'; 7 | ALTER TABLE settings ADD COLUMN episode_format TEXT NOT NULL DEFAULT '{}'; 8 | ALTER TABLE settings ADD COLUMN podcast_format TEXT NOT NULL DEFAULT '{}'; -------------------------------------------------------------------------------- /src/controllers/controller_utils.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::{DEFAULT_IMAGE_URL, ENVIRONMENT_SERVICE}; 2 | use serde_json::Value; 3 | 4 | pub fn unwrap_string(value: &Value) -> String { 5 | value.to_string().replace('\"', "") 6 | } 7 | 8 | pub fn unwrap_string_audio(value: &Value) -> String { 9 | match value.to_string().is_empty() { 10 | true => ENVIRONMENT_SERVICE.server_url.clone().to_owned() + DEFAULT_IMAGE_URL, 11 | false => value.to_string().replace('\"', ""), 12 | } 13 | } 14 | 15 | pub fn get_default_image() -> String { 16 | ENVIRONMENT_SERVICE.server_url.clone().to_owned() + DEFAULT_IMAGE_URL 17 | } 18 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Podfetch 8 | 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /docs/src/Introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | PodFetch is a simple, lightweight, fast, and efficient podcast downloader for hosting your own podcasts. 4 | It supports all the features you would expect from a podcast player, including: 5 | - Downloading podcasts 6 | - Listening to podcasts 7 | - Searching for podcasts 8 | - Managing your subscriptions 9 | - Managing your podcast episodes 10 | - Managing your podcast feed 11 | - Host your own Gpodder compatible podcast feed 12 | - Start listening on your phone and continue on your computer 13 | 14 | All you need to do is download the latest release from the release page or use the listed docker-compose file to get started. -------------------------------------------------------------------------------- /src/mutex.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LockResult; 2 | 3 | /// Extension methods for [`LockResult`]. 4 | /// 5 | /// [`LockResult`]: https://doc.rust-lang.org/stable/std/sync/type.LockResult.html 6 | pub trait LockResultExt { 7 | type Guard; 8 | 9 | /// Returns the lock guard even if the mutex is [poisoned]. 10 | /// 11 | /// [poisoned]: https://doc.rust-lang.org/stable/std/sync/struct.Mutex.html#poisoning 12 | fn ignore_poison(self) -> Self::Guard; 13 | } 14 | 15 | impl LockResultExt for LockResult { 16 | type Guard = Guard; 17 | 18 | fn ignore_poison(self) -> Guard { 19 | self.unwrap_or_else(|e| e.into_inner()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/adapters/api/models/device/device_response.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::models::device::model::Device; 2 | use utoipa::ToSchema; 3 | 4 | #[derive(Serialize, Deserialize, Clone, ToSchema)] 5 | pub struct DeviceResponse { 6 | id: String, 7 | caption: String, 8 | #[serde(rename = "type")] 9 | type_: String, 10 | subscriptions: u32, 11 | } 12 | 13 | impl From<&Device> for DeviceResponse { 14 | fn from(device: &Device) -> Self { 15 | DeviceResponse { 16 | id: device.deviceid.clone(), 17 | caption: device.name.clone(), 18 | type_: device.kind.clone(), 19 | subscriptions: 0, 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { LanguageDropdown } from './I18nDropdown' 2 | import { ThemeSelector } from './ThemeSelector' 3 | import { Notifications } from './Notifications' 4 | import { UserMenu } from './UserMenu' 5 | import useCommon from "../store/CommonSlice"; 6 | 7 | export const Header = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/components/RefreshIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type RefreshIconProps = { 4 | onClick: () => void 5 | } 6 | 7 | export const RefreshIcon: FC = ({ onClick }) => { 8 | return ( 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/prepare-traefik/main.tf: -------------------------------------------------------------------------------- 1 | variable "path-to-samples" { 2 | default = "./samples" 3 | } 4 | 5 | resource "local_file" "traefik_toml" { 6 | source = "${var.path-to-samples}/traefik.toml" 7 | filename = var.traefik_toml_location 8 | } 9 | resource "local_file" "traefik_access_log" { 10 | content = "" 11 | filename = var.traefik_access_log_location 12 | } 13 | 14 | resource "local_file" "traefik-dynamic-conf" { 15 | source = "${var.path-to-samples}/dynamic.toml" 16 | filename = var.traefik_dynamic_conf_location 17 | } 18 | 19 | resource "local_file" "traefik_acme_json" { 20 | filename = var.traefik_acme_location 21 | content = "" 22 | } -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/prepare-traefik/main.tf: -------------------------------------------------------------------------------- 1 | variable "path-to-samples" { 2 | default = "./samples" 3 | } 4 | 5 | resource "local_file" "traefik_toml" { 6 | source = "${var.path-to-samples}/traefik.toml" 7 | filename = var.traefik_toml_location 8 | } 9 | resource "local_file" "traefik_access_log" { 10 | content = "" 11 | filename = var.traefik_access_log_location 12 | } 13 | 14 | resource "local_file" "traefik-dynamic-conf" { 15 | source = "${var.path-to-samples}/dynamic.toml" 16 | filename = var.traefik_dynamic_conf_location 17 | } 18 | 19 | resource "local_file" "traefik-acme-conf" { 20 | filename = var.traefik_acme_location 21 | content = "" 22 | } -------------------------------------------------------------------------------- /ui/src/icons/PlayIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | import {PodcastEpisode} from "../store/CommonSlice"; 3 | 4 | export type IconProps = { 5 | className?: string, 6 | onClick?: () => void, 7 | podcast?: PodcastEpisode 8 | } 9 | 10 | export const PlayIcon:FC = ({className, onClick}) => { 11 | return
{ 12 | if (onClick) { 13 | onClick() 14 | } 15 | } 16 | }> 17 | 18 |
19 | } 20 | -------------------------------------------------------------------------------- /docs/src/Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Preamble 4 | 5 | First of all, thank you for considering contributing to Podfetch. It is people like you that make Podfetch great. 6 | I appreciate every contribution, no matter how small it is. If you have any questions, don't hesitate to ask them in 7 | the discussions section. 8 | 9 | ## Building the project 10 | 11 | ### Prerequisites 12 | - Rust 13 | - Cargo 14 | - Node 15 | - npm/yarn/pnpm 16 | 17 | ### Building the app 18 | ```bash 19 | # File just needs to be there 20 | touch static/index.html 21 | cargo.exe run --color=always --package podfetch --bin podfetch 22 | cd ui 23 | install 24 | run dev 25 | ``` -------------------------------------------------------------------------------- /migrations/postgres/2025-10-25-082817_podcast_episode_chapters/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE podcast_episode_chapters ( 3 | id TEXT PRIMARY KEY NOT NULL , 4 | episode_id INTEGER NOT NULL REFERENCES podcast_episodes(id) ON DELETE CASCADE, 5 | title TEXT NOT NULL, 6 | start_time INTEGER NOT NULL, 7 | end_time INTEGER NOT NULL, 8 | href TEXT, 9 | image TEXT, 10 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL , 11 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL 12 | ); 13 | 14 | CREATE UNIQUE INDEX uq_podcast_episode_chapters_episode_start 15 | ON podcast_episode_chapters (episode_id, start_time); -------------------------------------------------------------------------------- /src/controllers/file_hosting.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::ENVIRONMENT_SERVICE; 2 | use crate::utils::podcast_key_checker::check_permissions_for_files; 3 | use axum::middleware::from_fn; 4 | use axum::routing::get; 5 | use tower_http::services::ServeDir; 6 | use utoipa_axum::router::OpenApiRouter; 7 | 8 | pub fn podcast_serving() -> OpenApiRouter { 9 | OpenApiRouter::new().nest( 10 | "/podcasts", 11 | OpenApiRouter::new() 12 | .route("/trololol", get(|| async { "trololol" })) 13 | .fallback_service(ServeDir::new(&ENVIRONMENT_SERVICE.default_podfetch_folder)) 14 | .route_layer(from_fn(check_permissions_for_files)), 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/icons/BellIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | 3 | type BellIconProps = { 4 | onClick: () => void 5 | } 6 | 7 | export const BellIcon:FC = ({onClick}) => { 8 | return 10 | 11 | 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Automerge 2 | permissions: 3 | contents: write 4 | pull-requests: write 5 | on: 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | - reopened 11 | 12 | jobs: 13 | automerge: 14 | if: github.actor == 'dependabot[bot]' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Automerge 21 | uses: "pascalgn/automerge-action@v0.16.4" 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | MERGE_METHOD: squash 25 | MERGE_LABELS: "" 26 | MERGE_RETRY_SLEEP: "100000" 27 | -------------------------------------------------------------------------------- /docs/src/CLI.md: -------------------------------------------------------------------------------- 1 | # CLI usage 2 | 3 | The CLI can be used to manage users and to refresh & list subscribed podcasts. 4 | 5 | You can get help anytime by typing `--help` or `help`. 6 | 7 | # Usage 8 | 9 | ## Get general help 10 | 11 | ```bash 12 | podfetch --help 13 | ``` 14 | 15 | ## Get help for a specific command 16 | 17 | ```bash 18 | podfetch --help 19 | ``` 20 | 21 | e.g. 22 | 23 | ```bash 24 | podfetch users --help 25 | podfetch podcasts --help 26 | ``` 27 | 28 | 29 | ## Usage in docker 30 | 31 | ```bash 32 | docker ps #This will help you obtain the container's id and name 33 | docker exec -it /app/podfetch # Will execute your desired command in the container 34 | ``` -------------------------------------------------------------------------------- /ui/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import { Spinner } from './Spinner' 3 | import {FC} from "react"; 4 | import {cn} from "../lib/utils"; 5 | 6 | type LoadingSpinnerProps = { 7 | className?: string 8 | } 9 | 10 | export const Loading: FC = ({className}) => { 11 | const { t } = useTranslation() 12 | 13 | return ( 14 |
15 |
16 | 17 | 18 | {t('loading')}... 19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "baseUrl": ".", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "allowJs": false, 8 | "skipLibCheck": true, 9 | "esModuleInterop": false, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "noUncheckedIndexedAccess": true 20 | }, 21 | "include": ["src"], 22 | "references": [{ "path": "./tsconfig.node.json" }] 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/store/opmlImportSlice.ts: -------------------------------------------------------------------------------- 1 | import {create} from "zustand"; 2 | 3 | interface OpmlImportProps { 4 | progress: boolean[], 5 | messages: string[], 6 | inProgress: boolean, 7 | setProgress: (progress: boolean[]) => void, 8 | setMessages: (messages: string[]) => void, 9 | setInProgress: (inProgress: boolean) => void 10 | } 11 | 12 | 13 | const useOpmlImport = create((set) => ({ 14 | progress: [], 15 | messages:[], 16 | inProgress: false, 17 | setProgress: (progress: boolean[]) => set({progress}), 18 | setMessages: (messages: string[]) => set({messages}), 19 | setInProgress: (inProgress: boolean) => set({inProgress}) 20 | })) 21 | 22 | export default useOpmlImport 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 30 13 | - package-ecosystem: "npm" 14 | directory: "/ui" 15 | schedule: 16 | interval: "weekly" 17 | open-pull-requests-limit: 30 18 | -------------------------------------------------------------------------------- /Cross-postgres.toml: -------------------------------------------------------------------------------- 1 | [target."aarch64-unknown-linux-musl"] 2 | image.name="docker-registry.schwanzer.online/dockerhub_proxy/blackdex/rust-musl:aarch64-musl-1.69.0" 3 | image.toolchain = ["x86_64-unknown-linux-gnu"] 4 | env.passthrough = ["RUSTFLAGS"] 5 | 6 | [target."armv7-unknown-linux-musleabihf"] 7 | image.name="docker-registry.schwanzer.online/dockerhub_proxy/blackdex/rust-musl:armv7-musleabihf-stable-1.69.0" 8 | image.toolchain = ["x86_64-unknown-linux-gnu"] 9 | env.passthrough = ["RUSTFLAGS"] 10 | 11 | [target."x86_64-unknown-linux-musl"] 12 | image.name="docker-registry.schwanzer.online/dockerhub_proxy/blackdex/rust-musl:x86_64-musl-stable-1.69.0" 13 | image.toolchain = ["x86_64-unknown-linux-gnu"] 14 | env.passthrough = ["RUSTFLAGS"] 15 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/start-traefik/variables.tf: -------------------------------------------------------------------------------- 1 | variable "public_port" { 2 | description = "The public port to access the application" 3 | } 4 | 5 | variable "public_port_https" { 6 | description = "The public port to access the application" 7 | } 8 | 9 | 10 | variable "traefik_toml_location" { 11 | description = "The location of the traefik.toml file" 12 | } 13 | 14 | variable "traefik_acme_location" { 15 | description = "The location of the acme.json file" 16 | } 17 | 18 | 19 | variable "traefik_access_log_location" { 20 | description = "The location of the access.log file" 21 | } 22 | 23 | variable "traefik_dynamic_conf_location" { 24 | description = "The location of the dynamic configuration files" 25 | } -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/start-traefik/variables.tf: -------------------------------------------------------------------------------- 1 | variable "public_port" { 2 | description = "The public port to access the application" 3 | } 4 | 5 | variable "public_port_https" { 6 | description = "The public port to access the application" 7 | } 8 | 9 | 10 | variable "traefik_toml_location" { 11 | description = "The location of the traefik.toml file" 12 | } 13 | 14 | variable "traefik_acme_location" { 15 | description = "The location of the acme.json file" 16 | } 17 | 18 | 19 | variable "traefik_access_log_location" { 20 | description = "The location of the access.log file" 21 | } 22 | 23 | variable "traefik_dynamic_conf_location" { 24 | description = "The location of the dynamic configuration files" 25 | } -------------------------------------------------------------------------------- /ui/src/models/PodcastDefaultSettings.tsx: -------------------------------------------------------------------------------- 1 | import {components} from "../../schema"; 2 | 3 | export const generatePodcastDefaultSettings = (podcastId: number) => { 4 | return { 5 | activated: false, 6 | autoCleanup: false, 7 | autoCleanupDays: 0, 8 | autoDownload: true, 9 | autoUpdate: true, 10 | directPaths: false, 11 | episodeFormat: "", 12 | episodeNumbering: false, 13 | podcastFormat: "", 14 | podcastId: podcastId, 15 | podcastPrefill: 0, 16 | replaceInvalidCharacters: false, 17 | replacementStrategy: "replace-with-dash-and-underscore", 18 | useExistingFilename: false 19 | } satisfies components['schemas']['PodcastSetting'] 20 | } -------------------------------------------------------------------------------- /ui/src/components/MainContentPanel.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from 'react' 2 | import useCommon from '../store/CommonSlice' 3 | 4 | export const MainContentPanel: FC = ({ children }) => { 5 | const setSidebarCollapsed = useCommon(state => state.setSidebarCollapsed) 6 | const sidebarCollapsed = useCommon(state => state.sidebarCollapsed) 7 | 8 | return ( 9 |
10 | {/* Scrim for sidebar */} 11 |
{ setSidebarCollapsed(!sidebarCollapsed) }}>
12 | 13 | {children} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/ui/LoadingSkeletonSpan.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | import {Skeleton} from "./skeleton"; 3 | 4 | type LoadingSkeletonProps = { 5 | loading?: boolean, 6 | text?: string| undefined, 7 | height?: string, 8 | width?: string 9 | } 10 | 11 | export const LoadingSkeletonSpan: FC = ({ 12 | loading, 13 | text, 14 | height, width 15 | }) => { 16 | return ( 17 | {loading == true ? : text} 18 | ) 19 | } -------------------------------------------------------------------------------- /ui/src/models/AudioAmplifier.ts: -------------------------------------------------------------------------------- 1 | export class AudioAmplifier { 2 | constructor(private audioElement: HTMLAudioElement) { 3 | this.source = this.audioContext.createMediaElementSource(this.audioElement); 4 | this.source.connect(this.gainNode); 5 | this.gainNode.connect(this.audioContext.destination); 6 | } 7 | private audioContext = new AudioContext(); 8 | private gainNode = this.audioContext.createGain(); 9 | private readonly source; 10 | 11 | public setVolume(volume: number) { 12 | this.gainNode.gain.value = volume; 13 | } 14 | getSource(){ 15 | return this.source; 16 | } 17 | 18 | destroy(){ 19 | this.gainNode.disconnect() 20 | this.source.disconnect() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/utils/ErrorSnackBarResponses.ts: -------------------------------------------------------------------------------- 1 | import {enqueueSnackbar} from "notistack"; 2 | import {TFunction} from "i18next"; 3 | 4 | export const handleAddPodcast = (resp: number|null, podcast: string, t: TFunction)=>{ 5 | 6 | if (resp === null) { 7 | enqueueSnackbar(t('error'),{variant: "error"}) 8 | return 9 | } 10 | switch (resp) { 11 | case 409: 12 | enqueueSnackbar(t('already-added',{ 13 | name:podcast 14 | }),{variant: "error"}) 15 | break 16 | case 403: 17 | enqueueSnackbar(t('not-admin-or-uploader'),{variant: "error"}) 18 | break 19 | default: 20 | enqueueSnackbar(t('not-admin-or-uploader'),{variant: "error"}) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/podindex.md: -------------------------------------------------------------------------------- 1 | # Podcast Index 2 | 3 | It is also possible to retrieve/add podcasts from [Podcast Index](https://podcastindex.org/). 4 | To configure it you need to create an account on that website. After creating an account an email is sent to you with the required credentials. 5 | 6 | 7 | | Variable | Description | Default | 8 | |---------------------|---------------------------------------|---------| 9 | | PODINDEX_API_KEY | the api key sent to you via mail | % | 10 | | PODINDEX_API_SECRET | the api secret also found in the mail | % | 11 | 12 | * % means an empty string is configured as default 13 | 14 | After successful setup you should see on the settings page a green checkmark next to the Podindex config section. -------------------------------------------------------------------------------- /ui/src/icons/HeartIcon.tsx: -------------------------------------------------------------------------------- 1 | export const HeartIcon = () => { 2 | return 8 | } 9 | -------------------------------------------------------------------------------- /docs/src/FAQ.md: -------------------------------------------------------------------------------- 1 | # Pitfalls 2 | 3 | ## My server is not reachable from the internet 4 | 5 | - Check your firewall 6 | - Make sure you can ping the system 7 | 8 | ## My PodFetch server does not show any images 9 | 10 | - Make sure your `SERVER_URL` is set correctly 11 | - Make sure your `SERVER_URL` is reachable from the internet 12 | 13 | ## I cannot login to the UI 14 | 15 | - Make sure you have set up the `BASIC_AUTH` environment variable 16 | - Make sure you have set up the `USERNAME` environment variable 17 | - Make sure you have set up the `PASSWORD` environment variable 18 | - Otherwise, reset your password via the CLI 19 | 20 | ## I can't stream any podcasts with authentication enabled 21 | 22 | - Make sure your user has an api key 23 | - Otherwise, generate one via the UI in the profile tab. -------------------------------------------------------------------------------- /ui/src/icons/PodFetchIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | 3 | export const PodFetchIcon: FC = () => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/service/logging_service.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use env_logger::Builder; 3 | use log::{Level, LevelFilter}; 4 | use std::io::Write; 5 | 6 | pub fn init_logging() { 7 | Builder::new() 8 | .format(|buf, record| { 9 | let symbol = match record.level() { 10 | Level::Info => "ℹ️", 11 | Level::Error => "❌", 12 | Level::Warn => "⚠️", 13 | Level::Debug => "🐛", 14 | Level::Trace => "🔍", 15 | }; 16 | writeln!( 17 | buf, 18 | "{} {} - {}", 19 | Local::now().format("%Y-%m-%dT%H:%M:%S"), 20 | symbol, 21 | record.args() 22 | ) 23 | }) 24 | .filter(None, LevelFilter::Info) 25 | .init(); 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose-postgres.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | podfetch: 4 | image: samuel19982/podfetch:latest 5 | user: ${UID:-1000}:${GID:-1000} 6 | ports: 7 | - "80:8000" 8 | volumes: 9 | - ./podcasts:/app/podcasts 10 | environment: 11 | - POLLING_INTERVAL=300 12 | - SERVER_URL=http://localhost:80 13 | - DATABASE_URL=postgresql://postgres:changeme@postgres/podfetch 14 | depends_on: 15 | - postgres 16 | postgres: 17 | image: postgres 18 | environment: 19 | POSTGRES_USER: ${POSTGRES_USER:-postgres} 20 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} 21 | PGDATA: /data/postgres 22 | POSTGRES_DB: ${POSTGRES_DB:-podfetch} 23 | volumes: 24 | - postgres:/data/postgres 25 | restart: unless-stopped 26 | 27 | volumes: 28 | postgres: -------------------------------------------------------------------------------- /migrations/sqlite/2023-04-16-085031_podcast_names/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | ALTER TABLE podcasts RENAME COLUMN directory TO directory_id; 3 | ALTER TABLE podcasts ADD COLUMN directory_name VARCHAR(255) NOT NULL DEFAULT ''; 4 | 5 | PRAGMA foreign_keys=off; 6 | 7 | 8 | ALTER TABLE favorites RENAME TO _favorites_old; 9 | 10 | CREATE TABLE favorites ( 11 | username TEXT NOT NULL, 12 | podcast_id INTEGER NOT NULL, 13 | favored BOOLEAN NOT NULL DEFAULT 0, 14 | PRIMARY KEY (username, podcast_id), 15 | FOREIGN KEY (podcast_id) REFERENCES podcasts (id) ON DELETE CASCADE 16 | ); 17 | 18 | INSERT INTO favorites SELECT * FROM _favorites_old; 19 | 20 | DROP TABLE _favorites_old; 21 | PRAGMA foreign_keys=on; -------------------------------------------------------------------------------- /ui/src/components/CustomButtonSecondary.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | 3 | type CustomButtonSecondaryProps = { 4 | children: ReactNode, 5 | className?: string, 6 | disabled?: boolean, 7 | onClick?: () => void, 8 | type?: "button" | "submit" | "reset" 9 | } 10 | 11 | export const CustomButtonSecondary: FC = ({ children, className = '', disabled = false, onClick, type }) => { 12 | return ( 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod color; 2 | pub mod device_subscription; 3 | pub mod dto_models; 4 | pub mod episode; 5 | pub mod favorite_podcast_episode; 6 | pub mod favorites; 7 | pub mod file_path; 8 | pub mod filter; 9 | pub mod gpodder_available_podcasts; 10 | pub mod invite; 11 | pub mod itunes_models; 12 | pub mod misc_models; 13 | pub mod notification; 14 | pub mod opml_model; 15 | pub mod order_criteria; 16 | pub mod playlist; 17 | mod playlist_item; 18 | pub mod podcast_dto; 19 | pub mod podcast_episode; 20 | pub(crate) mod podcast_episode_chapter; 21 | pub mod podcast_rssadd_model; 22 | pub mod podcast_settings; 23 | pub mod podcasts; 24 | pub mod search_type; 25 | pub mod session; 26 | pub mod settings; 27 | pub mod subscription; 28 | pub mod subscription_changes_from_client; 29 | pub mod tag; 30 | pub mod tags_podcast; 31 | pub mod user; 32 | -------------------------------------------------------------------------------- /ui/src/icons/CloudIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | import {useTranslation} from "react-i18next"; 3 | 4 | type CloudIconProps = { 5 | className?: string, 6 | onClick?: ()=>void 7 | } 8 | 9 | export const CloudIcon:FC = ({className, onClick}) => { 10 | const {t} = useTranslation() 11 | 12 | return ( 13 |
14 | 15 | 16 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/build-documentation.yml: -------------------------------------------------------------------------------- 1 | name: Build mdBook 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'docs/**' 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Install mdBook 20 | uses: peaceiris/actions-mdbook@v1 21 | - name: Build mdBook 22 | run: mdbook build 23 | working-directory: docs 24 | - name: Upload artifact 25 | uses: actions/upload-pages-artifact@v1 26 | with: 27 | # Upload entire repository 28 | path: './docs/book' 29 | - name: Deploy to GitHub Pages 30 | id: deployment 31 | uses: actions/deploy-pages@v2 -------------------------------------------------------------------------------- /ui/src/components/I18nDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import i18n from '../language/i18n' 3 | import { CustomSelect } from './CustomSelect' 4 | import 'material-symbols/outlined.css' 5 | 6 | const languageOptions = [ 7 | { value: 'da', label: 'Dansk' }, 8 | { value: 'de-DE', label: 'Deutsch' }, 9 | { value: 'en', label: 'English' }, 10 | { value: 'fr', label: 'Français' }, 11 | { value: 'pl', label: 'Polski' }, 12 | { value: 'es', label: 'Español' } 13 | ] 14 | 15 | export const LanguageDropdown = () => { 16 | const [language, setLanguage] = useState(i18n.language) 17 | 18 | /* Responsiveness handled via stylesheet */ 19 | return {setLanguage(v); i18n.changeLanguage(v)}} options={languageOptions} value={language}/> 20 | } 21 | -------------------------------------------------------------------------------- /src/test_utils.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod test { 3 | use crate::constants::inner_constants::Role; 4 | use crate::models::user::User; 5 | use chrono::Utc; 6 | 7 | use sha256::digest; 8 | 9 | #[cfg(feature = "postgresql")] 10 | use testcontainers::{ContainerRequest, ImageExt}; 11 | #[cfg(feature = "postgresql")] 12 | use testcontainers_modules::postgres::Postgres; 13 | #[cfg(feature = "postgresql")] 14 | pub fn setup_container() -> ContainerRequest { 15 | Postgres::default().with_mapped_port(55002, 5432.into()) 16 | } 17 | 18 | pub fn create_random_user() -> User { 19 | User::new( 20 | 0, 21 | "testuser", 22 | Role::User, 23 | Some(digest("testuser")), 24 | Utc::now().naive_utc(), 25 | false, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /setup/terraform/README.md: -------------------------------------------------------------------------------- 1 | # Content of this folder 2 | 3 | This directory contains two other directories named: 4 | - postgres-docker: Contains postgres terraform configuration files 5 | - sqlite-docker: Contains sqlite configuration files 6 | 7 | 8 | ## How to use 9 | ### Install terraform 10 | - Download terraform from [here](https://www.terraform.io/downloads.html) 11 | - Extract the zip file 12 | - Add the extracted folder to your PATH environment variable 13 | - Run `terraform --version` to verify the installation 14 | - You should see something like this: 15 | 16 | - Before continuing review the variables in variables.tf and change accordingly. 17 | - Run `terraform init` to initialize terraform 18 | - Run `terraform plan` to see what terraform will do 19 | - Run `terraform apply` to apply the changes 20 | 21 | Now you should be able to access podfetch using the `SERVER_URL` 22 | -------------------------------------------------------------------------------- /ui/src/icons/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | 3 | type PlusIconProps = { 4 | className?: string, 5 | onClick?: ()=>void 6 | } 7 | 8 | export const PlusIcon:FC = ({className, onClick}) => { 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/icons/PodcastIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from "react"; 2 | 3 | type PodcastIconProps = { 4 | className?: string 5 | } 6 | 7 | export const PodcastIcon:FC = ({ className }) => { 8 | return 10 | } 11 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/start-db/postgres.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | resource "docker_network" "podfetch-internal" { 10 | name = "podfetch-internal" 11 | } 12 | resource "docker_container" "postgres-db" { 13 | name = "podfetch-db" 14 | image = "postgres" 15 | restart = "always" 16 | 17 | networks_advanced { 18 | name = "podfetch-internal" 19 | } 20 | 21 | env = [ 22 | "POSTGRES_USER=${var.db_user}", 23 | "POSTGRES_PASSWORD=${var.db_password}", 24 | "POSTGRES_DB=${var.db_name}" 25 | ] 26 | 27 | volumes { 28 | container_path = "/var/lib/postgresql/data" 29 | host_path = var.db_dir 30 | read_only = false 31 | } 32 | 33 | labels { 34 | label = "traefik.enable" 35 | value = "false" 36 | } 37 | 38 | 39 | } -------------------------------------------------------------------------------- /src/utils/reqwest_client.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::{COMMON_USER_AGENT, ENVIRONMENT_SERVICE}; 2 | use reqwest::Proxy; 3 | use reqwest::blocking::ClientBuilder; 4 | use reqwest::header::{HeaderMap, HeaderValue}; 5 | 6 | pub fn get_sync_client() -> ClientBuilder { 7 | let mut res = ClientBuilder::new(); 8 | let mut header_map = HeaderMap::new(); 9 | header_map.insert( 10 | "User-Agent", 11 | HeaderValue::from_str(COMMON_USER_AGENT).unwrap(), 12 | ); 13 | if let Some(unwrapped_proxy) = ENVIRONMENT_SERVICE.proxy_url.clone() { 14 | let proxy = Proxy::all(unwrapped_proxy); 15 | match proxy { 16 | Ok(e) => { 17 | res = res.proxy(e); 18 | } 19 | Err(e) => { 20 | log::error!("Proxy is invalid {e}") 21 | } 22 | } 23 | } 24 | 25 | res.default_headers(header_map) 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/components/CustomCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import * as Checkbox from '@radix-ui/react-checkbox' 3 | import 'material-symbols/outlined.css' 4 | 5 | type CustomCheckboxProps = { 6 | className?: string, 7 | id?: string, 8 | name?: string, 9 | onChange?: (checked: Checkbox.CheckedState)=>void, 10 | value?: Checkbox.CheckedState 11 | } 12 | 13 | export const CustomCheckbox: FC = ({ className = '', id, name, onChange, value }) => { 14 | return ( 15 | 16 | 17 | check 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile_cross_postgres: -------------------------------------------------------------------------------- 1 | FROM alpine:3 AS cache 2 | RUN apk add -U --no-cache ca-certificates 3 | 4 | FROM scratch as base 5 | COPY ./static/ /app/static 6 | COPY ./migrations /app/migrations 7 | COPY ./db /app/db 8 | WORKDIR /app/ 9 | 10 | FROM base as amd64 11 | COPY --from=cache /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 12 | COPY ./target/x86_64-unknown-linux-musl/release/podfetch /app/podfetch 13 | 14 | FROM base as armv7 15 | COPY --from=cache /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 16 | COPY ./target/armv7-unknown-linux-musleabihf/release/podfetch /app/podfetch 17 | 18 | FROM base as arm64 19 | COPY --from=cache /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 20 | COPY ./target/aarch64-unknown-linux-musl/release/podfetch /app/podfetch 21 | 22 | FROM ${TARGETARCH}${TARGETVARIANT} as final 23 | 24 | LABEL org.opencontainers.image.source="https://github.com/SamTV12345/PodFetch" 25 | 26 | 27 | EXPOSE 8000 28 | CMD ["./podfetch"] -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/start-podfetch/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | 10 | 11 | resource "docker_container" "podfetch" { 12 | name = "podfetch" 13 | image = "samuel19982/podfetch" 14 | restart = "always" 15 | labels { 16 | label = "traefik.enable" 17 | value = "true" 18 | } 19 | 20 | labels{ 21 | label = "traefik.http.routers.podfetch.rule" 22 | value = "Host(`${replace(var.server_url,"/(https?://)|(/)/","")}`)" 23 | } 24 | 25 | networks_advanced { 26 | name = "sqlite-traefik-proxy" 27 | } 28 | 29 | env = [ 30 | "SERVER_URL=${var.server_url}" 31 | ] 32 | 33 | volumes { 34 | container_path = "/app/podcasts" 35 | host_path = var.podcast_dir 36 | } 37 | 38 | volumes { 39 | container_path = "/app/db" 40 | host_path = var.db_dir 41 | } 42 | } -------------------------------------------------------------------------------- /ui/src/components/CustomInput.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEventHandler, FC, InputHTMLAttributes} from 'react' 2 | import {LoadingSkeletonSpan} from "./ui/LoadingSkeletonSpan"; 3 | 4 | 5 | export const CustomInput: FC & { 6 | loading?: boolean 7 | }> = ({ autoComplete, onBlur, className = '', id, name, onChange, disabled, placeholder, required, type = 'text', value, ...props }) => { 8 | if (props.loading) { 9 | return 10 | } 11 | return ( 12 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/test_builder/device_test_builder.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod tests { 3 | use crate::gpodder::device::dto::device_post::DevicePost; 4 | use fake::Fake; 5 | use fake::faker::lorem::en::Word; 6 | 7 | pub struct DevicePostTestDataBuilder { 8 | caption: String, 9 | r#type: String, 10 | } 11 | 12 | impl Default for DevicePostTestDataBuilder { 13 | fn default() -> Self { 14 | Self::new() 15 | } 16 | } 17 | 18 | impl DevicePostTestDataBuilder { 19 | pub fn new() -> DevicePostTestDataBuilder { 20 | DevicePostTestDataBuilder { 21 | r#type: "laptop".to_string(), 22 | caption: Word().fake(), 23 | } 24 | } 25 | 26 | pub fn build(self) -> DevicePost { 27 | DevicePost { 28 | caption: self.caption, 29 | kind: self.r#type, 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ui/src/components/AudioPlayer.tsx: -------------------------------------------------------------------------------- 1 | import {Activity, FC, RefObject} from 'react' 2 | import { AudioAmplifier } from '../models/AudioAmplifier' 3 | import { DrawerAudioPlayer } from './DrawerAudioPlayer' 4 | import { HiddenAudioPlayer } from './HiddenAudioPlayer' 5 | import useCommon from "../store/CommonSlice"; 6 | import useAudioPlayer from "../store/AudioPlayerSlice"; 7 | 8 | type AudioPlayerProps = { 9 | audioAmplifier: AudioAmplifier | undefined 10 | setAudioAmplifier: (audioAmplifier: AudioAmplifier | undefined) => void 11 | } 12 | 13 | export const AudioPlayer: FC = ({ audioAmplifier, setAudioAmplifier }) => { 14 | const loadedPodcastEpisode = useAudioPlayer(state => state.loadedPodcastEpisode) 15 | 16 | return 17 | 18 | 19 | 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/utils/FileUtils.ts: -------------------------------------------------------------------------------- 1 | export type FileItem = { 2 | name: string, 3 | content: string, 4 | json:MyFile, 5 | exists:boolean 6 | } 7 | 8 | export interface MyFile{ 9 | lastOpened: string, 10 | content:string, 11 | name:string, 12 | id:string, 13 | repo?:string 14 | } 15 | 16 | export const readFile = (file: File): Promise => { 17 | 18 | return new Promise((res, rej) => { 19 | const fileItem: FileItem = { 20 | name: file.name, 21 | content: "", 22 | json: {content:'',id:'',name:'',lastOpened:'',repo:''}, 23 | exists: false 24 | } 25 | 26 | const fr = new FileReader() 27 | 28 | fr.onload = async () => { 29 | const result = fr.result 30 | if (typeof result == "string") { 31 | fileItem.content = result 32 | res(fileItem) 33 | } 34 | } 35 | fr.readAsText(file) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/utils/AnimationFrameHook.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useRef} from "react"; 2 | 3 | type Args = any[] 4 | 5 | export const useAnimationFrame = void>( 6 | callback: Fn, 7 | wait = 0 8 | ): ((...args: Parameters)=>void)=>{ 9 | const rafId = useRef(0) 10 | const render = useCallback( 11 | (...args: Parameters)=>{ 12 | cancelAnimationFrame(rafId.current) 13 | const timeStart = performance.now() 14 | 15 | const renderFrame = (timeNow: number)=>{ 16 | if(timeNow-timeStartcancelAnimationFrame(rafId.current),[]) 28 | return render 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/checkFrontend.yml: -------------------------------------------------------------------------------- 1 | name: checkFrontend.yml 2 | permissions: 3 | contents: read 4 | on: 5 | pull_request: {} 6 | push: 7 | branches: 8 | - 'main' 9 | 10 | jobs: 11 | check-frontend: 12 | name: Check Frontend 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '24' 22 | - name: Set up pnpm 23 | uses: pnpm/action-setup@v2 24 | with: 25 | version: 10 26 | - name: Install dependencies 27 | working-directory: ui 28 | run: pnpm install 29 | - name: Test frontend 30 | working-directory: ui 31 | run: pnpm run test 32 | - name: Run tsc 33 | working-directory: ui 34 | run: pnpm run tsc:check 35 | - name: Build project 36 | working-directory: ui 37 | run: pnpm run build 38 | -------------------------------------------------------------------------------- /ui/src/components/SettingsInfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from 'react' 2 | import useCommon from '../store/CommonSlice' 3 | 4 | type SettingsInfoIconProps = { 5 | headerKey: string 6 | textKey: string, 7 | className?: string 8 | } 9 | 10 | export const SettingsInfoIcon: FC = ({ textKey, headerKey, className }) => { 11 | const setInfoModalPodcastOpen = useCommon(state => state.setInfoModalPodcastOpen) 12 | const setInfoText = useCommon(state => state.setInfoText) 13 | const setInfoHeading = useCommon(state => state.setInfoHeading) 14 | 15 | return ( 16 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/models/misc_models.rs: -------------------------------------------------------------------------------- 1 | use crate::adapters::api::models::podcast_episode_dto::PodcastEpisodeDto; 2 | use crate::models::episode::EpisodeDto; 3 | use crate::models::podcast_dto::PodcastDto; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Serialize, Deserialize, ToSchema)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct PodcastAddModel { 9 | pub track_id: i32, 10 | pub user_id: i32, 11 | } 12 | 13 | pub struct PodcastInsertModel { 14 | pub title: String, 15 | pub id: i32, 16 | pub feed_url: String, 17 | pub image_url: String, 18 | } 19 | 20 | #[derive(Serialize, Deserialize, ToSchema)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct PodcastWatchedPostModel { 23 | pub podcast_episode_id: String, 24 | pub time: i32, 25 | } 26 | 27 | #[derive(Serialize, Deserialize, ToSchema, Clone)] 28 | #[serde(rename_all = "camelCase")] 29 | pub struct PodcastWatchedEpisodeModelWithPodcastEpisode { 30 | pub podcast_episode: PodcastEpisodeDto, 31 | pub podcast: PodcastDto, 32 | pub episode: EpisodeDto, 33 | } 34 | -------------------------------------------------------------------------------- /ui/src/components/CustomButtonPrimary.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | import {LoadingSkeletonSpan} from "./ui/LoadingSkeletonSpan"; 3 | 4 | type CustomButtonPrimaryProps = { 5 | children: ReactNode, 6 | className?: string, 7 | disabled?: boolean, 8 | onClick?: () => void, 9 | type?: "button" | "submit" | "reset" 10 | loading?: boolean 11 | } 12 | 13 | export const CustomButtonPrimary: FC = ({ children, className = '', disabled = false, onClick, type = 'button', loading }) => { 14 | return ( 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/variables.tf: -------------------------------------------------------------------------------- 1 | variable "db_dir" { 2 | default = "/var/podfetch/db" 3 | } 4 | 5 | variable "podcast_dir" { 6 | default = "/var/podfetch/podcasts" 7 | } 8 | 9 | variable "server_url" { 10 | default = "http://podfetch.example.com" 11 | } 12 | 13 | variable "traefik-http-port" { 14 | default = "80" 15 | } 16 | 17 | variable "traefik-https-port" { 18 | default = "443" 19 | } 20 | 21 | variable "traefik_toml_location" { 22 | description = "The location of the traefik.toml file" 23 | default = "/etc/traefik/traefik.toml" 24 | } 25 | 26 | variable "traefik_acme_location" { 27 | description = "The location of the acme.json file" 28 | default = "/etc/traefik/acme.json" 29 | } 30 | 31 | 32 | variable "traefik_access_log_location" { 33 | description = "The location of the access.log file" 34 | default = "/var/log/traefik/access.log" 35 | } 36 | 37 | variable "traefik_dynamic_conf_location" { 38 | description = "The location of the dynamic configuration files" 39 | default = "/etc/traefik/dynamic_conf" 40 | } -------------------------------------------------------------------------------- /src/utils/gpodder_trimmer.rs: -------------------------------------------------------------------------------- 1 | pub fn trim_from_path(path_segment_with_extension: &str) -> (&str, &str) { 2 | let mut path_segment = path_segment_with_extension.split("."); 3 | 4 | let path_segment_first = path_segment.next().unwrap_or(path_segment_with_extension); 5 | (path_segment_first, path_segment.next().unwrap_or("")) 6 | } 7 | 8 | #[cfg(test)] 9 | mod tests { 10 | use super::*; 11 | use serial_test::serial; 12 | 13 | #[test] 14 | #[serial] 15 | fn test_trim_from_path() { 16 | let path_segment_with_extension = "src/utils/podcast_builder.rs"; 17 | let expected = "src/utils/podcast_builder"; 18 | let result = trim_from_path(path_segment_with_extension); 19 | assert_eq!(expected, result.0); 20 | } 21 | 22 | #[test] 23 | #[serial] 24 | fn test_trim_from_path_username() { 25 | let path_segment_with_extension = "max.json"; 26 | let expected = "max"; 27 | let result = trim_from_path(path_segment_with_extension); 28 | assert_eq!(expected, result.0); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/service/podcast_chapter.rs: -------------------------------------------------------------------------------- 1 | use chrono::Duration; 2 | 3 | pub type Image = url::Url; 4 | 5 | /// Represents a web link for the [chapter](crate::Chapter). 6 | #[derive(Debug, PartialEq, Clone)] 7 | pub struct Link { 8 | /// The URL of the link. 9 | pub url: url::Url, 10 | /// The title of the link. 11 | pub title: Option, 12 | } 13 | 14 | #[derive(Debug, PartialEq, Default)] 15 | pub struct Chapter { 16 | /// The starting time of the chapter. 17 | pub start: Duration, 18 | /// The end time of the chapter. 19 | pub end: Option, 20 | /// The title of this chapter. 21 | pub title: Option, 22 | /// The image to use as chapter art. 23 | pub image: Option, 24 | /// Web page or supporting document related to the topic of this chapter. 25 | pub link: Option, 26 | /// If this property is set to true, this chapter should not display visibly to the user in either the table of contents or as a jump-to point in the user interface. In the original spec, the inverse of this is called `toc`. 27 | pub hidden: bool, 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/language/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next' 2 | import {initReactI18next} from "react-i18next"; 3 | import LanguageDetector from 'i18next-browser-languagedetector' 4 | import da_translation from './json/da.json' 5 | import de_translation from './json/de.json' 6 | import en_translation from './json/en.json' 7 | import fr_translation from './json/fr.json' 8 | import pl_translation from './json/pl.json' 9 | import es_translation from './json/es.json' 10 | 11 | const resources = { 12 | da:{ 13 | translation: da_translation 14 | }, 15 | de: { 16 | translation: de_translation 17 | }, 18 | en:{ 19 | translation: en_translation 20 | }, 21 | fr:{ 22 | translation: fr_translation 23 | }, 24 | pl:{ 25 | translation: pl_translation 26 | }, 27 | es:{ 28 | translation: es_translation 29 | } 30 | } 31 | 32 | i18n 33 | .use(LanguageDetector) 34 | .use(initReactI18next) 35 | .init( 36 | { 37 | resources, 38 | fallbackLng: 'en' 39 | } 40 | ) 41 | 42 | export default i18n 43 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/start-podfetch/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | 10 | resource "docker_container" "podfetch" { 11 | name = "podfetch" 12 | image = "samuel19982/podfetch:latest" 13 | restart = "always" 14 | labels { 15 | label = "traefik.enable" 16 | value = "true" 17 | } 18 | 19 | labels{ 20 | label = "traefik.http.routers.podfetch.rule" 21 | value = "Host(`${replace(var.server_url,"/(https?://)|(/)/","")}`)" 22 | } 23 | 24 | networks_advanced { 25 | name = "postgres-traefik-proxy" 26 | } 27 | 28 | networks_advanced { 29 | name = "podfetch-internal" 30 | } 31 | 32 | env = [ 33 | "SERVER_URL=${var.server_url}", 34 | "DATABASE_URL=postgres://${var.db_user}:${var.db_password}@podfetch-db:5432/${var.db_name}", 35 | ] 36 | 37 | ports { 38 | internal = 8000 39 | external = 8000 40 | } 41 | 42 | volumes { 43 | container_path = "/app/podcasts" 44 | host_path = var.podcast_dir 45 | } 46 | } -------------------------------------------------------------------------------- /ui/src/routing/Root.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { Outlet, useNavigate } from 'react-router-dom' 3 | import useCommon from '../store/CommonSlice' 4 | import App from '../App' 5 | import { AudioComponents } from '../components/AudioComponents' 6 | import { EpisodeSearchModal } from '../components/EpisodeSearchModal' 7 | import { Header } from '../components/Header' 8 | import { MainContentPanel } from '../components/MainContentPanel' 9 | import { Sidebar } from '../components/Sidebar' 10 | 11 | 12 | export const Root = () => { 13 | return ( 14 | 15 |
16 | 17 | 18 |
19 |
20 | 21 |
22 | 23 | 24 | 25 |
26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /migrations/sqlite/2024-08-25-170551_episode_numbering/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | 3 | ALTER TABLE podcast_episodes ADD COLUMN episode_numbering_processed BOOLEAN NOT NULL DEFAULT FALSE; 4 | CREATE TABLE podcast_settings ( 5 | podcast_id INTEGER PRIMARY KEY NOT NULL, 6 | episode_numbering BOOLEAN NOT NULL DEFAULT FALSE, 7 | auto_download BOOLEAN NOT NULL DEFAULT FALSE, 8 | auto_update BOOLEAN NOT NULL DEFAULT TRUE, 9 | auto_cleanup BOOLEAN NOT NULL DEFAULT FALSE, 10 | auto_cleanup_days INTEGER NOT NULL DEFAULT -1, 11 | replace_invalid_characters BOOLEAN DEFAULT TRUE NOT NULL, 12 | use_existing_filename BOOLEAN DEFAULT FALSE NOT NULL, 13 | replacement_strategy TEXT CHECK(replacement_strategy IN 14 | ('replace-with-dash-and-underscore', 'remove', 'replace-with-dash')) NOT NULL DEFAULT 15 | 'replace-with-dash-and-underscore', 16 | episode_format TEXT NOT NULL DEFAULT '{}', 17 | podcast_format TEXT NOT NULL DEFAULT '{}', 18 | direct_paths BOOLEAN NOT NULL DEFAULT FALSE, 19 | activated BOOLEAN NOT NULL DEFAULT FALSE, 20 | podcast_prefill INTEGER NOT NULL DEFAULT 0 21 | ); -------------------------------------------------------------------------------- /ui/src/models/messages/BroadcastMesage.ts: -------------------------------------------------------------------------------- 1 | import {Podcast, PodcastEpisode} from "../../store/CommonSlice"; 2 | import {components} from "../../../schema"; 3 | 4 | export interface BroadcastMesage { 5 | type_of: string, 6 | message: string, 7 | 8 | 9 | } 10 | 11 | export interface PodcastAdded extends BroadcastMesage { 12 | podcast: components["schemas"]["PodcastDto"] 13 | } 14 | 15 | export interface PodcastRefreshed extends BroadcastMesage { 16 | podcast: components["schemas"]["PodcastDto"] 17 | } 18 | export interface PodcastEpisodeAdded extends BroadcastMesage, PodcastAdded { 19 | podcast_episode: components["schemas"]["PodcastEpisodeDto"] 20 | } 21 | 22 | export interface PodcastEpisodeDeleted extends BroadcastMesage, PodcastAdded { 23 | podcast_episode: components["schemas"]["PodcastEpisodeDto"] 24 | } 25 | 26 | export interface PodcastEpisodeAdded extends BroadcastMesage { 27 | podcast_episode: components["schemas"]["PodcastEpisodeDto"] 28 | } 29 | 30 | export interface PodcastEpisodesAdded extends BroadcastMesage, PodcastAdded { 31 | podcast_episodes: components["schemas"]["PodcastEpisodeDto"][] 32 | } 33 | -------------------------------------------------------------------------------- /migrations/postgres/2024-08-25-170551_episode_numbering/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | 3 | ALTER TABLE podcast_episodes ADD COLUMN episode_numbering_processed BOOLEAN NOT NULL DEFAULT FALSE; 4 | CREATE TABLE podcast_settings ( 5 | podcast_id INTEGER PRIMARY KEY NOT NULL, 6 | episode_numbering BOOLEAN NOT NULL DEFAULT FALSE, 7 | auto_download BOOLEAN NOT NULL DEFAULT FALSE, 8 | auto_update BOOLEAN NOT NULL DEFAULT TRUE, 9 | auto_cleanup BOOLEAN NOT NULL DEFAULT FALSE, 10 | auto_cleanup_days INTEGER NOT NULL DEFAULT -1, 11 | replace_invalid_characters BOOLEAN DEFAULT TRUE NOT NULL, 12 | use_existing_filename BOOLEAN DEFAULT FALSE NOT NULL, 13 | replacement_strategy TEXT CHECK(replacement_strategy IN 14 | ('replace-with-dash-and-underscore', 'remove', 'replace-with-dash')) NOT NULL DEFAULT 15 | 'replace-with-dash-and-underscore', 16 | episode_format TEXT NOT NULL DEFAULT '{}', 17 | podcast_format TEXT NOT NULL DEFAULT '{}', 18 | direct_paths BOOLEAN NOT NULL DEFAULT FALSE, 19 | activated BOOLEAN NOT NULL DEFAULT FALSE, 20 | podcast_prefill INTEGER NOT NULL DEFAULT 0 21 | ); -------------------------------------------------------------------------------- /src/gpodder/parametrization.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::ENVIRONMENT_SERVICE; 2 | use axum::Json; 3 | use axum::routing::get; 4 | use utoipa_axum::router::OpenApiRouter; 5 | 6 | #[derive(Serialize, Deserialize)] 7 | pub struct ClientParametrization { 8 | mygpo: BaseURL, 9 | #[serde(rename = "mygpo-feedservice")] 10 | mygpo_feedservice: BaseURL, 11 | update_timeout: i32, 12 | } 13 | 14 | #[derive(Serialize, Deserialize)] 15 | pub struct BaseURL { 16 | #[serde(rename = "baseurl")] 17 | base_url: String, 18 | } 19 | 20 | pub async fn get_client_parametrization() -> Json { 21 | let answer = ClientParametrization { 22 | mygpo_feedservice: BaseURL { 23 | base_url: ENVIRONMENT_SERVICE.server_url.clone(), 24 | }, 25 | mygpo: BaseURL { 26 | base_url: ENVIRONMENT_SERVICE.server_url.to_string() + "rss", 27 | }, 28 | update_timeout: 604800, 29 | }; 30 | 31 | Json(answer) 32 | } 33 | 34 | pub fn get_client_parametrization_router() -> OpenApiRouter { 35 | OpenApiRouter::new().route("/clientconfig.json", get(get_client_parametrization)) 36 | } 37 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-03-183844_search/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | PRAGMA foreign_keys = off; 3 | PRAGMA defer_foreign_keys=on; 4 | PRAGMA ignore_check_constraints=on; 5 | 6 | create table podcasts2 7 | ( 8 | id integer not null primary key, 9 | name text not null, 10 | directory_id text not null, 11 | rssfeed text not null, 12 | image_url text not null, 13 | summary TEXT, 14 | language TEXT, 15 | explicit TEXT, 16 | keywords TEXT, 17 | last_build_date TEXT, 18 | author TEXT, 19 | active BOOLEAN default TRUE not null, 20 | original_image_url VARCHAR(255) default '' not null, 21 | directory_name VARCHAR(255) default '' not null 22 | ); 23 | 24 | INSERT INTO podcasts2 SELECT * FROM podcasts; 25 | 26 | DROP TABLE podcasts; 27 | ALTER TABLE podcasts2 RENAME TO podcasts; 28 | 29 | PRAGMA foreign_keys=on; 30 | PRAGMA defer_foreign_keys=off; 31 | PRAGMA ignore_check_constraints=off; -------------------------------------------------------------------------------- /src/utils/http_client.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::{COMMON_USER_AGENT, ENVIRONMENT_SERVICE}; 2 | use reqwest::header::{HeaderMap, HeaderValue}; 3 | use reqwest::{Client, Proxy}; 4 | use std::sync::OnceLock; 5 | 6 | static HTTP_CLIENT: OnceLock = OnceLock::new(); 7 | 8 | pub fn get_http_client() -> Client { 9 | HTTP_CLIENT 10 | .get_or_init(|| get_async_sync_client().build().unwrap()) 11 | .clone() 12 | } 13 | 14 | pub fn get_async_sync_client() -> reqwest::ClientBuilder { 15 | let mut res = reqwest::ClientBuilder::new(); 16 | let mut header_map = HeaderMap::new(); 17 | header_map.insert( 18 | "User-Agent", 19 | HeaderValue::from_str(COMMON_USER_AGENT).unwrap(), 20 | ); 21 | 22 | if let Some(unwrapped_proxy) = ENVIRONMENT_SERVICE.proxy_url.clone() { 23 | let proxy = Proxy::all(unwrapped_proxy); 24 | match proxy { 25 | Ok(e) => { 26 | res = res.proxy(e); 27 | } 28 | Err(e) => { 29 | log::error!("Proxy is invalid {e}") 30 | } 31 | } 32 | } 33 | 34 | res.default_headers(header_map) 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podfetch 2 | 3 | [![dependency status](https://deps.rs/repo/github/SamTV12345/PodFetch/status.svg)](https://deps.rs/repo/github/SamTV12345/PodFetch) 4 | [![Build documentation](https://github.com/SamTV12345/PodFetch/actions/workflows/build-documentation.yml/badge.svg)](https://github.com/SamTV12345/PodFetch/actions/workflows/build-documentation.yml) 5 | [![Build](https://github.com/SamTV12345/PodFetch/actions/workflows/pr-build.yml/badge.svg)](https://github.com/SamTV12345/PodFetch/actions/workflows/pr-build.yml) 6 | 7 | Podfetch is a self-hosted podcast manager. 8 | It is a web app that lets you download podcasts and listen to them online. 9 | It is written in Rust and uses React for the frontend. 10 | It also contains a GPodder integration, so you can continue using your current podcast app. 11 | 12 | Every time a new commit is pushed to the main branch, a new docker image is built and pushed to docker hub. So it is best to use something like [watchtower](https://github.com/containrrr/watchtower) to automatically update the docker image. 13 | 14 | 15 | You can find the documentation with a UI preview [here](https://samtv12345.github.io/PodFetch/). 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/application/services/device/service.rs: -------------------------------------------------------------------------------- 1 | use crate::adapters::persistence::repositories::device::device_repository::DeviceRepositoryImpl; 2 | use crate::application::repositories::device_repository::DeviceRepository; 3 | use crate::application::usecases::devices::create_use_case::CreateUseCase; 4 | use crate::application::usecases::devices::edit_use_case::EditUseCase; 5 | use crate::application::usecases::devices::query_use_case::QueryUseCase; 6 | use crate::domain::models::device::model::Device; 7 | use crate::utils::error::CustomError; 8 | 9 | pub struct DeviceService; 10 | 11 | impl CreateUseCase for DeviceService { 12 | fn create(device_to_safe: Device) -> Result { 13 | DeviceRepositoryImpl::create(device_to_safe) 14 | } 15 | } 16 | 17 | impl QueryUseCase for DeviceService { 18 | fn query_by_username(username: &str) -> Result, CustomError> { 19 | DeviceRepositoryImpl::get_devices_of_user(username) 20 | } 21 | } 22 | 23 | impl EditUseCase for DeviceService { 24 | fn delete_by_username(username: &str) -> Result<(), CustomError> { 25 | DeviceRepositoryImpl::delete_by_username(username) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/models/socketioEvents.ts: -------------------------------------------------------------------------------- 1 | import {components} from "../../schema"; 2 | 3 | export interface ServerToClientEvents { 4 | offlineAvailable: (data: { 5 | podcast: components["schemas"]["PodcastDto"] 6 | type_of: string, 7 | podcast_episode: components["schemas"]["PodcastEpisodeDto"] 8 | })=>void, 9 | refreshedPodcast: (data: { 10 | message: string, 11 | podcast: components["schemas"]["PodcastDto"], 12 | })=>void, 13 | opmlError: (data: { 14 | message: string 15 | })=>void, 16 | opmlAdded: (data: { 17 | message: string 18 | })=>void, 19 | addedEpisodes: (data: { 20 | message: string, 21 | podcast: components["schemas"]["PodcastDto"], 22 | podcast_episodes: components["schemas"]["PodcastEpisodeDto"][] 23 | })=>void, 24 | deletedPodcastEpisodeLocally: (data: { 25 | podcast_episode: components["schemas"]["PodcastEpisodeDto"], 26 | message: string 27 | })=>void, 28 | addedPodcast: (data: { 29 | message: string, 30 | podcast: components["schemas"]["PodcastDto"] 31 | })=>void, 32 | } 33 | 34 | 35 | export interface ClientToServerEvents { 36 | 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/utils/PlayHandler.ts: -------------------------------------------------------------------------------- 1 | import {prepareOnlinePodcastEpisode, preparePodcastEpisode} from "./Utilities"; 2 | import useCommon from "../store/CommonSlice"; 3 | import {components} from "../../schema"; 4 | 5 | export const handlePlayofEpisode = (episode: components["schemas"]["PodcastEpisodeDto"], chapters: components['schemas']['PodcastChapterDto'][], response?: components["schemas"]["EpisodeDto"])=>{ 6 | if (response == null){ 7 | return episode.status 8 | ? preparePodcastEpisode(episode,chapters, response) 9 | : prepareOnlinePodcastEpisode(episode,chapters, response ) 10 | } 11 | const playedPercentage = response.position! * 100 / episode.total_time 12 | if(playedPercentage < 95 || episode.total_time === 0){ 13 | return episode.status 14 | ? preparePodcastEpisode(episode,chapters, response) 15 | : prepareOnlinePodcastEpisode(episode,chapters, response ) 16 | } 17 | else{ 18 | useCommon.getState().setPodcastEpisodeAlreadyPlayed({ 19 | podcastEpisode: episode, 20 | podcastHistoryItem: response 21 | }) 22 | useCommon.getState().setPodcastAlreadyPlayed(true) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-04-09-122459_user_management/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE if not exists users ( 3 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | username VARCHAR(255) NOT NULL, 5 | role TEXT CHECK(role IN ('admin', 'uploader', 'user')) NOT NULL, 6 | password VARCHAR(255) NULL, 7 | explicit_consent BOOLEAN NOT NULL DEFAULT 0, 8 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | UNIQUE (username) 10 | ); 11 | 12 | CREATE TABLE invites ( 13 | id VARCHAR(255) NOT NULL PRIMARY KEY, 14 | role TEXT CHECK(role IN ('admin', 'uploader', 'user')) NOT NULL, 15 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | accepted_at DATETIME NULL, 17 | explicit_consent BOOLEAN NOT NULL DEFAULT 0, 18 | expires_at DATETIME NOT NULL 19 | ); 20 | 21 | ALTER TABLE podcasts DROP COLUMN favored; 22 | 23 | CREATE TABLE favorites ( 24 | username TEXT NOT NULL, 25 | podcast_id INTEGER NOT NULL, 26 | favored BOOLEAN NOT NULL DEFAULT 0, 27 | PRIMARY KEY (username, podcast_id), 28 | FOREIGN KEY (username) REFERENCES users (username) ON DELETE CASCADE, 29 | FOREIGN KEY (podcast_id) REFERENCES podcasts (id) ON DELETE CASCADE 30 | ); -------------------------------------------------------------------------------- /migrations/sqlite/2023-05-03-183844_search/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | PRAGMA foreign_keys = off; 3 | PRAGMA defer_foreign_keys=on; 4 | PRAGMA ignore_check_constraints=on; 5 | 6 | create table podcasts2 7 | ( 8 | id integer not null 9 | primary key, 10 | name text not null 11 | unique, 12 | directory_id text not null, 13 | rssfeed text not null, 14 | image_url text not null, 15 | summary TEXT, 16 | language TEXT, 17 | explicit TEXT, 18 | keywords TEXT, 19 | last_build_date TEXT, 20 | author TEXT, 21 | active BOOLEAN default TRUE not null, 22 | original_image_url VARCHAR(255) default '' not null, 23 | directory_name VARCHAR(255) default '' not null 24 | ); 25 | 26 | INSERT INTO podcasts2 SELECT * FROM podcasts; 27 | 28 | DROP TABLE podcasts; 29 | ALTER TABLE podcasts2 RENAME TO podcasts; 30 | 31 | PRAGMA foreign_keys=on; 32 | PRAGMA defer_foreign_keys=off; 33 | PRAGMA ignore_check_constraints=off; -------------------------------------------------------------------------------- /ui/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | #root, body, html{ 6 | height: 100%; 7 | } 8 | .App-logo { 9 | height: 40vmin; 10 | pointer-events: none; 11 | } 12 | 13 | @media (prefers-reduced-motion: no-preference) { 14 | .App-logo { 15 | animation: App-logo-spin infinite 20s linear; 16 | } 17 | } 18 | 19 | @media (min-width: 768px) { 20 | #authorTable tr:last-child{ 21 | border-bottom-right-radius: 1rem; /* 16px */ 22 | border-bottom-left-radius: 1rem; /* 16px */ 23 | } 24 | 25 | #authorTable tr:last-child td:last-child { 26 | border-bottom-right-radius: 1rem; /* 16px */ 27 | } 28 | 29 | #authorTable tr:last-child td:first-child { 30 | border-bottom-left-radius: 1rem; /* 16px */ 31 | } 32 | } 33 | 34 | .App-header { 35 | background-color: #282c34; 36 | min-height: 100vh; 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | justify-content: center; 41 | font-size: calc(10px + 2vmin); 42 | color: white; 43 | } 44 | 45 | .App-link { 46 | color: #61dafb; 47 | } 48 | 49 | @keyframes App-logo-spin { 50 | from { 51 | transform: rotate(0deg); 52 | } 53 | to { 54 | transform: rotate(360deg); 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-03-02-101007_create_podcast_episode_table/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | create table if not exists podcast_episodes ( 3 | id integer primary key not null, 4 | podcast_id integer not null, 5 | episode_id TEXT not null, 6 | name text not null, 7 | url text not null, 8 | date_of_recording text not null, 9 | image_url text not null, 10 | total_time integer DEFAULT 0 not null, 11 | local_url text DEFAULT '' not null, 12 | local_image_url text DEFAULT '' not null, 13 | description text DEFAULT '' not null, 14 | FOREIGN KEY (podcast_id) REFERENCES podcasts(id)); 15 | CREATE INDEX IF NOT EXISTS podcast_episodes_podcast_id_index ON podcast_episodes (podcast_id); -------------------------------------------------------------------------------- /ui/src/icons/VolumeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC, RefObject, useState } from 'react' 2 | import { IconProps } from './PlayIcon' 3 | import 'material-symbols/outlined.css' 4 | import {getAudioPlayer} from "../utils/audioPlayer"; 5 | 6 | interface VolumeProps extends IconProps { 7 | max: number, 8 | volume: number 9 | } 10 | 11 | export const VolumeIcon: FC = ({ max, volume }) => { 12 | const [muted, setMuted] = useState(false) 13 | 14 | return muted ? ( 15 | { 16 | const audioPlayer = getAudioPlayer() 17 | audioPlayer.muted = false 18 | setMuted(false) 19 | }}>volume_off 20 | ) : ( 21 | { 22 | const audioPlayer = getAudioPlayer() 23 | audioPlayer.muted = true 24 | setMuted(true) 25 | }}>{(volume === 0) ? 'volume_mute' : ((volume / max) < 0.5) ? 'volume_down' : 'volume_up'} 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/adapters/persistence/model/device/device_entity.rs: -------------------------------------------------------------------------------- 1 | use crate::adapters::persistence::dbconfig::schema::devices; 2 | use crate::domain::models::device::model::Device; 3 | use diesel::{Insertable, Queryable, QueryableByName}; 4 | use utoipa::ToSchema; 5 | 6 | #[derive(Serialize, Deserialize, Queryable, Insertable, QueryableByName, Clone, ToSchema)] 7 | #[diesel(table_name=devices)] 8 | pub struct DeviceEntity { 9 | #[diesel(deserialize_as = i32)] 10 | pub id: Option, 11 | pub deviceid: String, 12 | pub kind: String, 13 | pub name: String, 14 | pub username: String, 15 | } 16 | 17 | impl From for DeviceEntity { 18 | fn from(value: Device) -> Self { 19 | DeviceEntity { 20 | id: value.id, 21 | deviceid: value.deviceid, 22 | kind: value.kind, 23 | name: value.name, 24 | username: value.username, 25 | } 26 | } 27 | } 28 | 29 | impl From for Device { 30 | fn from(val: DeviceEntity) -> Self { 31 | Device { 32 | id: val.id, 33 | kind: val.kind, 34 | name: val.name, 35 | deviceid: val.deviceid, 36 | username: val.username, 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /migrations/postgres/2024-11-29-083801_tags/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | 3 | CREATE TABLE tags ( 4 | id TEXT NOT NULL PRIMARY KEY, 5 | name TEXT NOT NULL, 6 | username TEXT NOT NULL, 7 | description TEXT, 8 | created_at TIMESTAMP NOT NULL, 9 | color TEXT NOT NULL, 10 | UNIQUE (name, username) 11 | ); 12 | 13 | CREATE TABLE tags_podcasts 14 | ( 15 | tag_id TEXT NOT NULL, 16 | podcast_id INTEGER NOT NULL, 17 | FOREIGN KEY (tag_id) REFERENCES tags (id), 18 | FOREIGN KEY (podcast_id) REFERENCES podcasts (id), 19 | PRIMARY KEY (tag_id, podcast_id) 20 | ); 21 | 22 | -- INDEXES 23 | CREATE INDEX idx_tags_name ON tags (name); 24 | CREATE INDEX idx_tags_username ON tags (username); 25 | CREATE INDEX idx_devices ON devices(name); 26 | CREATE INDEX idx_episodes_podcast ON episodes(podcast); 27 | CREATE INDEX idx_episodes_episode ON episodes(episode); 28 | CREATE INDEX idx_podcast_episodes ON podcast_episodes(podcast_id); 29 | CREATE INDEX idx_podcast_episodes_url ON podcast_episodes(url); 30 | CREATE INDEX idx_podcasts_name ON podcasts(name); 31 | CREATE INDEX idx_podcasts_rssfeed ON podcasts(rssfeed); 32 | CREATE INDEX idx_subscriptions ON subscriptions(username); 33 | CREATE INDEX idx_subscriptions_device ON subscriptions(device); -------------------------------------------------------------------------------- /migrations/sqlite/2024-11-29-083801_tags/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | 3 | CREATE TABLE tags ( 4 | id TEXT NOT NULL PRIMARY KEY, 5 | name TEXT NOT NULL, 6 | username TEXT NOT NULL, 7 | description TEXT, 8 | created_at TIMESTAMP NOT NULL, 9 | color TEXT NOT NULL, 10 | UNIQUE (name, username) 11 | ); 12 | 13 | CREATE TABLE tags_podcasts 14 | ( 15 | tag_id TEXT NOT NULL, 16 | podcast_id INTEGER NOT NULL, 17 | FOREIGN KEY (tag_id) REFERENCES tags (id), 18 | FOREIGN KEY (podcast_id) REFERENCES podcasts (id), 19 | PRIMARY KEY (tag_id, podcast_id) 20 | ); 21 | 22 | 23 | -- INDEXES 24 | CREATE INDEX idx_tags_name ON tags (name); 25 | CREATE INDEX idx_tags_username ON tags (username); 26 | CREATE INDEX idx_devices ON devices(name); 27 | CREATE INDEX idx_episodes_podcast ON episodes(podcast); 28 | CREATE INDEX idx_episodes_episode ON episodes(episode); 29 | CREATE INDEX idx_podcast_episodes ON podcast_episodes(podcast_id); 30 | CREATE INDEX idx_podcast_episodes_url ON podcast_episodes(url); 31 | CREATE INDEX idx_podcasts_name ON podcasts(name); 32 | CREATE INDEX idx_podcasts_rssfeed ON podcasts(rssfeed); 33 | CREATE INDEX idx_subscriptions ON subscriptions(username); 34 | CREATE INDEX idx_subscriptions_device ON subscriptions(device); -------------------------------------------------------------------------------- /docs/src/S3.md: -------------------------------------------------------------------------------- 1 | # S3 configuration 2 | 3 | So you want to use an S3 compatible storage backend to e.g. host files central or save costs for storage provisioning in the cloud? PodFetch now also supports S3 configuration. 4 | This is also valuable if you want to use a self-hosted MinIO instance and don't want to map and mount volumes around. 5 | It is currently necessary that you have your files in S3 configured readonly so people can stream them from there. 6 | 7 | 8 | | Environment variable | Description | Default | 9 | |----------------------|---------------------------------------|-----------------------| 10 | | `S3_URL` | The URL of the S3 service. | http://localhost:9000 | 11 | | `S3_REGION` | The region of the S3 service. | eu-west-1 | 12 | | `S3_ACCESS_KEY` | The access key of the S3 service. | / | 13 | | `S3_SECRET_KEY` | The secret key of the S3 service. | / | 14 | | `S3_PROFILE` | The profile of the S3 service. | / | 15 | | `S3_SECURITY_TOKEN` | The security token of the S3 service. | / | 16 | | `S3_SESSION_TOKEN` | The session token of the S3 service. | / | 17 | -------------------------------------------------------------------------------- /ui/src/utils/login.ts: -------------------------------------------------------------------------------- 1 | type LoginObject = { 2 | loginType: 'oidc' | 'basic', 3 | rememberMe: boolean 4 | } 5 | 6 | 7 | export const LoginKey = 'login' 8 | 9 | export const getLogin = (): LoginObject | null => { 10 | const item = localStorage.getItem(LoginKey) || sessionStorage.getItem(LoginKey) 11 | if (item) { 12 | return JSON.parse(item) as LoginObject 13 | } 14 | return null 15 | } 16 | 17 | 18 | export const setAuth = (auth: string) => { 19 | const login = getLogin() 20 | if (login) { 21 | if (login.loginType === 'basic') { 22 | if (login.rememberMe) { 23 | localStorage.setItem('auth', auth) 24 | } else { 25 | sessionStorage.setItem('auth', auth) 26 | } 27 | } else if (login.loginType === 'oidc') { 28 | if (login.rememberMe) { 29 | localStorage.setItem('auth', auth) 30 | } else { 31 | sessionStorage.setItem('auth', auth) 32 | } 33 | } 34 | } 35 | } 36 | 37 | export const setLogin = (login: LoginObject) => { 38 | localStorage.setItem('login', JSON.stringify(login)) 39 | } 40 | 41 | 42 | export const removeLogin = () => { 43 | sessionStorage.removeItem('auth') 44 | localStorage.removeItem('auth') 45 | } -------------------------------------------------------------------------------- /src/service/telegram_api.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::ENVIRONMENT_SERVICE; 2 | use crate::models::podcast_episode::PodcastEpisode; 3 | use crate::models::podcasts::Podcast; 4 | use frankenstein::client_ureq::Bot; 5 | use frankenstein::methods::SendMessageParams; 6 | use frankenstein::{ParseMode, TelegramApi}; 7 | 8 | pub fn send_new_episode_notification(podcast_episode: PodcastEpisode, podcast: Podcast) { 9 | let telegram_config = ENVIRONMENT_SERVICE.telegram_api.clone().unwrap(); 10 | let api = Bot::new(&telegram_config.telegram_bot_token); 11 | 12 | let episode_text = format!( 13 | "Episode {} of podcast {} \ 14 | was downloaded successfully and is ready to be listened to.", 15 | podcast_episode.name, podcast.name 16 | ); 17 | let message_to_send = format!(r"New episode available: {episode_text}"); 18 | 19 | let message = SendMessageParams::builder() 20 | .chat_id(telegram_config.telegram_chat_id.to_string()) 21 | .text(message_to_send) 22 | .parse_mode(ParseMode::Html) 23 | .build(); 24 | let telegram_res = api.send_message(&message); 25 | match telegram_res { 26 | Ok(_) => {} 27 | Err(e) => { 28 | log::error!("Error sending telegram message: {e}"); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/test_builder/notification_test_builder.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod tests { 3 | use crate::models::notification::Notification; 4 | use derive_builder::Builder; 5 | use fake::faker::chrono::en::Time; 6 | use fake::{Fake, Faker}; 7 | 8 | #[derive(Default, Builder, Debug)] 9 | #[builder(setter(into))] 10 | pub struct NotificationTestDataBuilder { 11 | pub message: String, 12 | pub status: String, 13 | pub created_at: String, 14 | } 15 | 16 | impl NotificationTestDataBuilder { 17 | pub fn new() -> NotificationTestDataBuilder { 18 | NotificationTestDataBuilder { 19 | status: "unread".to_string(), 20 | message: Faker.fake::(), 21 | created_at: Time().fake::(), 22 | } 23 | } 24 | pub fn random() -> NotificationTestDataBuilder { 25 | NotificationTestDataBuilder::new() 26 | } 27 | 28 | pub fn build(self) -> Notification { 29 | Notification { 30 | id: 0, 31 | message: Faker.fake::(), 32 | status: self.status, 33 | created_at: Time().fake(), 34 | type_of_message: "Download".to_string(), 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/components/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import { useTranslation } from 'react-i18next' 4 | import useCommon from '../store/CommonSlice' 5 | import 'material-symbols/outlined.css' 6 | 7 | type SidebarItemProps = { 8 | path: string, 9 | translationKey: string, 10 | iconName: string, 11 | spaceBefore?: boolean 12 | } 13 | 14 | export const SidebarItem: FC = ({ path, translationKey, iconName, spaceBefore }) => { 15 | const setSidebarCollapsed = useCommon(state => state.setSidebarCollapsed) 16 | const { t } = useTranslation() 17 | 18 | const minimizeOnMobile = () => { 19 | if (window.screen.width < 768) { 20 | setSidebarCollapsed(true) 21 | } 22 | } 23 | 24 | return ( 25 |
  • minimizeOnMobile()} className={spaceBefore ? "space-before" : ""}> 26 | 27 | {iconName} 28 | {t(translationKey)} 29 | 30 |
  • 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/models/SysInfo.ts: -------------------------------------------------------------------------------- 1 | import {CPUModel} from "./CPUModel"; 2 | import {SysUser} from "./SysUser"; 3 | 4 | export interface SysInfo { 5 | IS_SUPPORTED: boolean, 6 | SUPPORTED_SIGNALS: string[], 7 | MINIMUM_CPU_UPDATE_INTERVAL: { 8 | secs: number, 9 | nanos: number 10 | }, 11 | global_cpu_usage: number 12 | cpus: CPUModelAggregate, 13 | physical_core_count: number, 14 | mem_total: number, 15 | mem_available: number, 16 | used_memory: number, 17 | total_swap: number, 18 | free_swap: number, 19 | used_swap: number, 20 | components: [], 21 | users: SysUser[], 22 | os_version: string, 23 | long_os_version: string, 24 | name: string, 25 | kernel_version: string, 26 | distribution_id: string, 27 | host_name: string, 28 | 29 | } 30 | 31 | type CPUModelAggregate = { 32 | cpus: CPUModel[], 33 | global: number 34 | } 35 | 36 | 37 | export interface ConfigModel { 38 | podindexConfigured: boolean, 39 | rssFeed: string 40 | serverUrl: string, 41 | wsUrl: string, 42 | basicAuth: string, 43 | oidcConfigured: boolean, 44 | oidcConfig?: { 45 | authority: string, 46 | clientId: string, 47 | redirectUri: string, 48 | scope: string, 49 | }, 50 | reverseProxy: boolean 51 | } 52 | -------------------------------------------------------------------------------- /docs/src/UIWalkthrough.md: -------------------------------------------------------------------------------- 1 | # UI 2 | 3 | ## Audio Player 4 | The podcast listening tool contains an advanced audio player that can be used to listen to your podcasts,skip episodes, turn the volumes as high as 300% or skip around in the current episode. 5 | ![Audio Player](./images/advanced_audio_player.png) 6 | 7 | # Continue right where you stopped 8 | 9 | The tool will automatically save your progress in the current episode and will resume from where you left off even if you close the browser. 10 | You can continue listening on all devices by just hitting play on any episode on your home screen. 11 | 12 | ![Continue listening to episodes](./images/continue_listening.png) 13 | 14 | ## Search for podcasts 15 | You can search for podcast episodes by hitting CTRL+F and typing any word that might appear in the description or title of the podcast episode you want to listen to. 16 | ![Audio Player](./images/search.png) 17 | 18 | 19 | Below are some fullscreen images so you can get a better grasp of the UI. 20 | 21 | ## Home Screen 22 | ![Home Screen](./images/home.png) 23 | 24 | 25 | ## Timeline View 26 | 27 | ![Timeline](./images/timeline.png) 28 | 29 | ## Info View 30 | 31 | ![Info page](./images/Info_Page.png) 32 | 33 | 34 | ## Settings 35 | 36 | ![Settings](./images/settings.png) 37 | 38 | ## Administration 39 | 40 | ![Administration](./images/administration.png) -------------------------------------------------------------------------------- /ui/src/hooks/useKeyDown.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react"; 2 | 3 | export const useKeyDown = (callback:any, keys:string[], triggerOnInputField:boolean=true) => { 4 | const onKeyDown = (event: KeyboardEvent) => { 5 | if (!triggerOnInputField && (event.target as HTMLElement).tagName === 'INPUT') { 6 | return; 7 | } 8 | const wasAnyKeyPressed = keys.some((key: string) => event.key === key); 9 | if (wasAnyKeyPressed) { 10 | event.preventDefault(); 11 | callback(); 12 | } 13 | }; 14 | useEffect(() => { 15 | document.addEventListener('keydown', onKeyDown); 16 | return () => { 17 | document.removeEventListener('keydown', onKeyDown); 18 | }; 19 | }, [onKeyDown]); 20 | }; 21 | 22 | export const useCtrlPressed = (callback:any, keys:string[]) => { 23 | const onKeyDown = (event: KeyboardEvent) => { 24 | const wasAnyKeyPressed = keys.some((key: string) => event.key === key && event.ctrlKey); 25 | if (wasAnyKeyPressed) { 26 | event.preventDefault(); 27 | callback(); 28 | } 29 | }; 30 | useEffect(() => { 31 | document.addEventListener('keydown', onKeyDown); 32 | return () => { 33 | document.removeEventListener('keydown', onKeyDown); 34 | }; 35 | }, [onKeyDown]); 36 | }; 37 | -------------------------------------------------------------------------------- /ui/src/components/PlaylistData.tsx: -------------------------------------------------------------------------------- 1 | import {CustomInput} from "./CustomInput"; 2 | import {useTranslation} from "react-i18next"; 3 | import usePlaylist from "../store/PlaylistSlice"; 4 | 5 | 6 | 7 | 8 | export const PlaylistData = ()=>{ 9 | const {t} = useTranslation() 10 | const currentPlaylistToEdit = usePlaylist(state=>state.currentPlaylistToEdit) 11 | const setCurrentPlaylistToEdit = usePlaylist(state=>state.setCurrentPlaylistToEdit) 12 | 13 | const changeName = (e:string)=>{ 14 | 15 | currentPlaylistToEdit && setCurrentPlaylistToEdit({ 16 | name: e, 17 | id: currentPlaylistToEdit!.id, 18 | items: currentPlaylistToEdit!.items 19 | }) 20 | } 21 | 22 | 23 | return
    24 |
    25 | 26 | 27 |
    28 |
    29 | changeName(e.target.value)} value ={currentPlaylistToEdit?.name} /> 30 | 31 |
    32 |
    33 |
    34 |
    35 | } 36 | -------------------------------------------------------------------------------- /migrations/sqlite/2023-04-23-115251_gpodder_api/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE devices( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | deviceid VARCHAR(255) NOT NULL, 5 | kind TEXT CHECK(kind IN ('desktop', 'laptop', 'server','mobile', 'Other')) NOT NULL, 6 | name VARCHAR(255) NOT NULL, 7 | username VARCHAR(255) NOT NULL, 8 | FOREIGN KEY (username) REFERENCES users(username) 9 | ); 10 | 11 | CREATE TABLE sessions( 12 | username VARCHAR(255) NOT NULL, 13 | session_id VARCHAR(255) NOT NULL, 14 | expires DATETIME NOT NULL, 15 | PRIMARY KEY (username, session_id) 16 | ); 17 | 18 | CREATE TABLE subscriptions( 19 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 20 | username TEXT NOT NULL, 21 | device TEXT NOT NULL, 22 | podcast TEXT NOT NULL, 23 | created Datetime NOT NULL, 24 | deleted Datetime, 25 | UNIQUE (username, device, podcast) 26 | ); 27 | 28 | CREATE TABLE episodes( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 30 | username VARCHAR(255) NOT NULL, 31 | device VARCHAR(255) NOT NULL, 32 | podcast VARCHAR(255) NOT NULL, 33 | episode VARCHAR(255) NOT NULL, 34 | timestamp DATETIME NOT NULL, 35 | guid VARCHAR(255), 36 | action VARCHAR(255) NOT NULL, 37 | started INTEGER, 38 | position INTEGER, 39 | total INTEGER, 40 | UNIQUE (username, device, podcast, episode, timestamp) 41 | ); -------------------------------------------------------------------------------- /ui/src/store/PlaylistSlice.ts: -------------------------------------------------------------------------------- 1 | import {create} from "zustand"; 2 | import {components} from "../../schema"; 3 | 4 | interface PlaylistState { 5 | playlist: components["schemas"]["PlaylistDto"][], 6 | createPlaylistOpen: boolean, 7 | currentPlaylistToEdit: components["schemas"]["PlaylistDto"]|undefined, 8 | selectedPlaylist: components["schemas"]["PlaylistDto"]|undefined, 9 | setPlaylist: (playlist: components["schemas"]["PlaylistDto"][]) => void, 10 | setCreatePlaylistOpen: (createPlaylistOpen: boolean) => void, 11 | setCurrentPlaylistToEdit: (currentPlaylistToEdit: components["schemas"]["PlaylistDto"]) => void, 12 | setSelectedPlaylist: (selectedPlaylist: components["schemas"]["PlaylistDto"]) => void 13 | } 14 | 15 | 16 | const usePlaylist = create((set, get) => ({ 17 | playlist: [], 18 | createPlaylistOpen: false, 19 | selectedPlaylist: undefined, 20 | currentPlaylistToEdit: undefined, 21 | setPlaylist: (playlist: components["schemas"]["PlaylistDto"][]) => set({playlist}), 22 | setCreatePlaylistOpen: (createPlaylistOpen: boolean) => set({createPlaylistOpen}), 23 | setSelectedPlaylist: (selectedPlaylist: components["schemas"]["PlaylistDto"]) => set({selectedPlaylist}), 24 | setCurrentPlaylistToEdit: (currentPlaylistToEdit: components["schemas"]["PlaylistDto"]) => set({currentPlaylistToEdit}) 25 | })) 26 | 27 | export default usePlaylist 28 | -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/setup.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | 10 | provider "docker" { 11 | host = "unix:///var/run/docker.sock" 12 | } 13 | 14 | 15 | module "prepare-traefik" { 16 | source = "./prepare-traefik" 17 | traefik_access_log_location = var.traefik_access_log_location 18 | traefik_acme_location = var.traefik_acme_location 19 | traefik_dynamic_conf_location = var.traefik_dynamic_conf_location 20 | traefik_toml_location = var.traefik_toml_location 21 | } 22 | 23 | module "deploy-traefik-images" { 24 | source = "./start-traefik" 25 | depends_on = [module.prepare-traefik] 26 | providers = { 27 | docker = docker 28 | } 29 | public_port = var.traefik-http-port 30 | public_port_https = var.traefik-https-port 31 | traefik_access_log_location = var.traefik_access_log_location 32 | traefik_acme_location = var.traefik_acme_location 33 | traefik_dynamic_conf_location = var.traefik_dynamic_conf_location 34 | traefik_toml_location = var.traefik_toml_location 35 | } 36 | 37 | module "deploy-podfetch" { 38 | source = "./start-podfetch" 39 | 40 | depends_on = [module.deploy-traefik-images] 41 | db_dir = var.db_dir 42 | podcast_dir = var.podcast_dir 43 | server_url = var.server_url 44 | } -------------------------------------------------------------------------------- /src/utils/rss_feed_parser.rs: -------------------------------------------------------------------------------- 1 | use rss::Channel; 2 | 3 | #[derive(Clone)] 4 | pub struct PodcastParsed { 5 | pub title: String, 6 | pub language: String, 7 | pub explicit: String, 8 | pub keywords: String, 9 | pub summary: String, 10 | pub date: String, 11 | } 12 | 13 | pub struct RSSFeedParser; 14 | 15 | impl RSSFeedParser { 16 | pub fn parse_rss_feed(rss_feed: Channel) -> PodcastParsed { 17 | let title = rss_feed.title; 18 | let language = rss_feed.language.unwrap_or("en".to_string()); 19 | let build_date = rss_feed.last_build_date.unwrap_or("".to_string()); 20 | let keywords = match rss_feed.itunes_ext.clone() { 21 | Some(itunes_ext) => itunes_ext.keywords.unwrap_or("".to_string()), 22 | None => "".to_string(), 23 | }; 24 | let summary = match rss_feed.itunes_ext.clone() { 25 | Some(itunes_ext) => itunes_ext.summary.unwrap_or("".to_string()), 26 | None => "".to_string(), 27 | }; 28 | 29 | let explicit = match rss_feed.itunes_ext { 30 | Some(itunes_ext) => itunes_ext.explicit.unwrap_or("".to_string()), 31 | None => "".to_string(), 32 | }; 33 | 34 | PodcastParsed { 35 | title, 36 | language, 37 | explicit, 38 | keywords, 39 | summary, 40 | date: build_date, 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/auth.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod tests { 3 | use crate::commands::startup::tests::TestServerWrapper; 4 | use crate::models::user::User; 5 | use base64::Engine; 6 | use base64::engine::general_purpose; 7 | 8 | pub fn create_basic_header(username: &str, password: &str) -> String { 9 | general_purpose::STANDARD.encode(format!("{username}:{password}")) 10 | } 11 | 12 | pub async fn create_auth_gpodder(server: &mut TestServerWrapper<'_>, user: &User) { 13 | let encoded_auth = 14 | general_purpose::STANDARD.encode(format!("{}:{}", user.username, "password")); 15 | server.test_server.clear_headers(); 16 | server 17 | .test_server 18 | .add_header("Authorization", format!("Basic {encoded_auth}")); 19 | 20 | let response = { 21 | let server_ref = &mut server.test_server; 22 | server_ref 23 | .post(&format!("/api/2/auth/{}/login.json", user.username)) 24 | .await 25 | }; 26 | assert_eq!(response.status_code().as_u16(), 200); 27 | // get devices 28 | let cookie_binding = response.cookies(); 29 | server 30 | .test_server 31 | .add_cookie(cookie_binding.get("sessionid").unwrap().clone()); 32 | assert!(response.status_code().is_success()); 33 | assert!(response.cookies().get("sessionid").is_some()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ui/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/utils/LazyLoading.ts: -------------------------------------------------------------------------------- 1 | import {lazy} from "react" 2 | 3 | export const PodcastViewLazyLoad = lazy(()=>import('../pages/Podcasts').then(module=> { 4 | return{default:module["Podcasts"]} 5 | })) 6 | 7 | export const PodcastDetailViewLazyLoad = lazy(()=>import('../pages/PodcastDetailPage').then(module=> { 8 | return{default:module["PodcastDetailPage"]} 9 | })) 10 | 11 | export const PodcastInfoViewLazyLoad = lazy(()=>import('../pages/SystemInfoPage').then(module=> { 12 | return{default:module["SystemInfoPage"]} 13 | })) 14 | 15 | export const SettingsViewLazyLoad = lazy(()=>import('../pages/SettingsPage').then(module=> { 16 | return{default:module["SettingsPage"]} 17 | })) 18 | 19 | export const UserAdminViewLazyLoad = lazy(()=>import('../pages/UserAdminPage').then(module=> { 20 | return{default:module["UserAdminPage"]} 21 | })) 22 | 23 | export const TimeLineViewLazyLoad = lazy(()=>import('../pages/Timeline').then(module=> { 24 | return{default:module["Timeline"]} 25 | })) 26 | 27 | export const EpisodeSearchViewLazyLoad = lazy(()=>import('../pages/EpisodeSearchPage').then(module=> { 28 | return{default:module["EpisodeSearchPage"]} 29 | })) 30 | 31 | export const PlaylistViewLazyLoad = lazy(()=>import('../pages/PlaylistDetailPage').then(module=> { 32 | return{default:module["PlaylistDetailPage"]} 33 | })) 34 | 35 | export const HomepageViewLazyLoad = lazy(()=>import('../pages/Homepage').then(module=> { 36 | return{default:module["Homepage"]} 37 | })) 38 | -------------------------------------------------------------------------------- /ui/src/components/PlaylistSubmitViewer.tsx: -------------------------------------------------------------------------------- 1 | import {CustomButtonPrimary} from "./CustomButtonPrimary"; 2 | import usePlaylist from "../store/PlaylistSlice"; 3 | import {useTranslation} from "react-i18next"; 4 | import {client} from "../utils/http"; 5 | 6 | export const PlaylistSubmitViewer = ()=>{ 7 | const {t} = useTranslation() 8 | const currentPlaylistToEdit = usePlaylist(state=>state.currentPlaylistToEdit) 9 | const playlists = usePlaylist(state=>state.playlist) 10 | const setCreatePlaylistOpen = usePlaylist(state=>state.setCreatePlaylistOpen) 11 | const setPlaylist = usePlaylist(state=>state.setPlaylist) 12 | 13 | const savePlaylist = ()=>{ 14 | const idsToMap = currentPlaylistToEdit!.items.map(item=>{ 15 | return{ 16 | episode: item.podcastEpisode.id 17 | }}) 18 | 19 | client.POST("/api/v1/playlist", { 20 | body: { 21 | name: currentPlaylistToEdit?.name!, 22 | items: idsToMap 23 | } 24 | }) 25 | .then((v)=>{ 26 | setPlaylist([...playlists,v.data!]) 27 | setCreatePlaylistOpen(false) 28 | }) 29 | } 30 | 31 | 32 | return <> 33 | { 34 | savePlaylist() 35 | }}>{currentPlaylistToEdit?.id==="-1"?t('create-playlist'):t('update-playlist')} 36 |
    37 | 38 | } 39 | -------------------------------------------------------------------------------- /setup/terraform/sqlite-docker/start-traefik/start-traefik.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | 10 | resource "docker_network" "sqlite-traefik-proxy" { 11 | name = "sqlite-traefik-proxy" 12 | } 13 | 14 | 15 | resource "docker_container" "traefik" { 16 | name = "traefik_proxy" 17 | image = "traefik" 18 | restart = "always" 19 | ports { 20 | internal = 80 21 | external = var.public_port 22 | } 23 | 24 | ports { 25 | internal = 443 26 | external = var.public_port_https 27 | } 28 | 29 | networks_advanced { 30 | name = "sqlite-traefik-proxy" 31 | } 32 | 33 | volumes { 34 | container_path = "/var/run/docker.sock" 35 | host_path = "/var/run/docker.sock" 36 | read_only = false 37 | } 38 | 39 | volumes { 40 | container_path = "/etc/traefik/traefik.toml" 41 | host_path = var.traefik_toml_location 42 | read_only = true 43 | } 44 | 45 | volumes { 46 | container_path = "/etc/traefik/acme.json" 47 | host_path = var.traefik_acme_location 48 | read_only = false 49 | } 50 | 51 | volumes { 52 | container_path = "/etc/traefik/dynamic.toml" 53 | host_path = var.traefik_dynamic_conf_location 54 | read_only = true 55 | } 56 | 57 | volumes { 58 | container_path = "/var/log/traefik/access.log" 59 | host_path = var.traefik_access_log_location 60 | read_only = false 61 | } 62 | } -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/start-traefik/start-traefik.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | 10 | resource "docker_network" "postgres-traefik-proxy" { 11 | name = "postgres-traefik-proxy" 12 | } 13 | 14 | 15 | resource "docker_container" "traefik" { 16 | name = "traefik_proxy" 17 | image = "traefik" 18 | restart = "always" 19 | ports { 20 | internal = 80 21 | external = var.public_port 22 | } 23 | 24 | ports { 25 | internal = 443 26 | external = var.public_port_https 27 | } 28 | 29 | networks_advanced { 30 | name = "postgres-traefik-proxy" 31 | } 32 | 33 | volumes { 34 | container_path = "/var/run/docker.sock" 35 | host_path = "/var/run/docker.sock" 36 | read_only = false 37 | } 38 | 39 | volumes { 40 | container_path = "/etc/traefik/traefik.toml" 41 | host_path = var.traefik_toml_location 42 | read_only = true 43 | } 44 | 45 | volumes { 46 | container_path = "/etc/traefik/acme.json" 47 | host_path = var.traefik_acme_location 48 | read_only = false 49 | } 50 | 51 | volumes { 52 | container_path = "/etc/traefik/dynamic.toml" 53 | host_path = var.traefik_dynamic_conf_location 54 | read_only = true 55 | } 56 | 57 | volumes { 58 | container_path = "/var/log/traefik/access.log" 59 | host_path = var.traefik_access_log_location 60 | read_only = false 61 | } 62 | } -------------------------------------------------------------------------------- /ui/src/components/ConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import { CustomButtonPrimary } from './CustomButtonPrimary' 2 | import { CustomButtonSecondary } from './CustomButtonSecondary' 3 | import { Modal } from './Modal' 4 | import useCommon from "../store/CommonSlice"; 5 | 6 | export type ConfirmModalProps = { 7 | headerText: string, 8 | onAccept: () => void, 9 | onReject: () => void, 10 | acceptText: string, 11 | rejectText: string, 12 | bodyText: string 13 | } 14 | 15 | export const ConfirmModal = () => { 16 | const confirmModalData = useCommon(state => state.confirmModalData) 17 | 18 | return ( 19 | {}} cancelText={confirmModalData?.rejectText} onCancel={() => {}} onDelete={() => {}}> 20 |
    21 | {confirmModalData?.bodyText} 22 |
    23 |
    24 | {confirmModalData?.rejectText} 25 | {confirmModalData?.acceptText} 26 |
    27 |
    28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | type SpinnerProps = { 4 | className?: string 5 | } 6 | 7 | export const Spinner: FC = ({ className }) => { 8 | return ( 9 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/pages/HomePageSelector.tsx: -------------------------------------------------------------------------------- 1 | import {useTranslation} from "react-i18next"; 2 | import {useState} from "react"; 3 | import {Heading1} from "../components/Heading1"; 4 | import {NavLink, Outlet, useNavigate} from "react-router-dom"; 5 | 6 | export const HomePageSelector = ()=>{ 7 | const {t} = useTranslation() 8 | return ( 9 | <> 10 |
    11 | {t('homepage')} 12 | 13 | {/* Tabs */} 14 |
      15 | 16 | 17 | home {t('homepage')} 18 | 19 | 20 | 21 | 22 | playlist_play {t('playlists')} 23 | 24 | 25 |
    26 | 27 |
    28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/test_builder/user_test_builder.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod tests { 3 | use crate::models::user::User; 4 | use fake::Fake; 5 | use fake::faker::internet::raw::Username; 6 | use fake::locales::EN; 7 | 8 | pub struct UserTestDataBuilder { 9 | id: i32, 10 | username: String, 11 | role: String, 12 | password: Option, 13 | explicit_consent: bool, 14 | created_at: chrono::DateTime, 15 | api_key: Option, 16 | } 17 | 18 | impl Default for UserTestDataBuilder { 19 | fn default() -> Self { 20 | Self::new() 21 | } 22 | } 23 | 24 | impl UserTestDataBuilder { 25 | pub fn new() -> UserTestDataBuilder { 26 | UserTestDataBuilder { 27 | id: 1, 28 | username: Username(EN).fake(), 29 | role: "user".to_string(), 30 | password: Some(sha256::digest("password".to_string())), 31 | explicit_consent: true, 32 | created_at: chrono::Utc::now(), 33 | api_key: Some("api_key".to_string()), 34 | } 35 | } 36 | 37 | pub fn build(self) -> User { 38 | User { 39 | id: self.id, 40 | explicit_consent: self.explicit_consent, 41 | username: self.username, 42 | password: self.password, 43 | created_at: self.created_at.naive_utc(), 44 | api_key: self.api_key, 45 | role: self.role, 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/controllers/manifest_controller.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::inner_constants::ENVIRONMENT_SERVICE; 2 | use crate::utils::error::CustomError; 3 | use axum::Json; 4 | use axum::routing::get; 5 | use utoipa_axum::router::OpenApiRouter; 6 | 7 | #[derive(Serialize)] 8 | pub struct Icon { 9 | pub src: String, 10 | pub sizes: String, 11 | pub r#type: String, 12 | } 13 | 14 | #[derive(Serialize)] 15 | pub struct Manifest { 16 | pub name: String, 17 | pub short_name: String, 18 | pub start_url: String, 19 | pub icons: Vec, 20 | pub theme_color: String, 21 | pub background_color: String, 22 | pub display: String, 23 | pub orientation: String, 24 | } 25 | 26 | pub async fn get_manifest() -> Result, CustomError> { 27 | let mut icons = Vec::new(); 28 | let icon = Icon { 29 | src: ENVIRONMENT_SERVICE.server_url.to_string() + "ui/logo.png", 30 | sizes: "512x512".to_string(), 31 | r#type: "image/png".to_string(), 32 | }; 33 | icons.push(icon); 34 | 35 | let manifest = Manifest { 36 | name: "PodFetch".to_string(), 37 | short_name: "PodFetch".to_string(), 38 | start_url: ENVIRONMENT_SERVICE.server_url.to_string(), 39 | icons, 40 | orientation: "landscape".to_string(), 41 | theme_color: "#ffffff".to_string(), 42 | display: "fullscreen".to_string(), 43 | background_color: "#ffffff".to_string(), 44 | }; 45 | Ok(Json(manifest)) 46 | } 47 | 48 | pub fn get_manifest_router() -> OpenApiRouter { 49 | OpenApiRouter::new().route("/manifest.json", get(get_manifest)) 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/components/AddPodcastModal.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { AddHeader } from './AddHeader' 4 | import { AddTypes } from '../models/AddTypes' 5 | import { FeedURLComponent } from './FeedURLComponent' 6 | import { Modal } from './Modal' 7 | import { OpmlAdd } from './OpmlAdd' 8 | import { ProviderImportComponent } from './ProviderImportComponent' 9 | import useCommon from "../store/CommonSlice"; 10 | import {$api} from "../utils/http"; 11 | 12 | export const AddPodcastModal: FC = () => { 13 | const {t} = useTranslation() 14 | const [selectedSearchType, setSelectedSearchType] = useState(AddTypes.ITUNES) 15 | const configModel = $api.useQuery('get', '/api/v1/sys/config') 16 | 17 | return ( 18 | {}} onAccept={() => {}} headerText={t('add-podcast')!} onDelete={() => {}} cancelText={"Abbrechen"} acceptText={"Hinzufügen"}> 19 | {configModel.data && } 20 | 21 | {selectedSearchType !== AddTypes.OPML && selectedSearchType !== AddTypes.FEED && 22 | 23 | } 24 | {selectedSearchType === AddTypes.OPML && 25 | 26 | } 27 | {selectedSearchType === AddTypes.FEED && 28 | 29 | } 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/components/Switcher.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import {LoadingSkeletonSpan} from "./ui/LoadingSkeletonSpan"; 3 | 4 | type SwitcherProps = { 5 | checked?: boolean, 6 | className?: string, 7 | id?: string, 8 | onChange: (checked: boolean) => void, 9 | disabled?: boolean 10 | loading?: boolean 11 | } 12 | 13 | export const Switcher: FC = ({ checked, className = '', id, onChange, disabled, loading }) => { 14 | if (loading) { 15 | return ( 16 | 17 | ) 18 | } 19 | return ( 20 |
    { 21 | if (disabled) return 22 | onChange(!checked) 23 | }}> 24 | { 25 | onChange(!checked) 26 | }} type="checkbox" value="" /> 27 | 28 |
    34 |
    35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /ui/src/components/FeedURLComponent.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { useForm } from 'react-hook-form' 3 | import { useTranslation } from 'react-i18next' 4 | import { handleAddPodcast } from '../utils/ErrorSnackBarResponses' 5 | import { CustomButtonPrimary } from './CustomButtonPrimary' 6 | import {client} from "../utils/http"; 7 | 8 | type FeedURLFormData = { 9 | feedUrl: string 10 | } 11 | 12 | export const FeedURLComponent: FC = () => { 13 | const { t } = useTranslation() 14 | 15 | const { register, handleSubmit, formState: { 16 | isDirty, isValid 17 | } } = useForm({ 18 | defaultValues: { 19 | feedUrl: '' 20 | } 21 | }) 22 | 23 | 24 | const onSubmit = (data: FeedURLFormData) => { 25 | client.POST("/api/v1/podcasts/feed", { 26 | body: { 27 | rssFeedUrl: data.feedUrl 28 | } 29 | }) 30 | .then((v) => { 31 | handleAddPodcast(v.response.status, v.data!.name, t) 32 | }) 33 | } 34 | 35 | return ( 36 |
    37 | 41 | 42 | {t('add')} 43 |
    44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/variables.tf: -------------------------------------------------------------------------------- 1 | variable "postgres_user" { 2 | default = "podfetch" 3 | description = "The postgres user for podfetch" 4 | } 5 | 6 | 7 | variable "postgres_password" { 8 | default = "podfetch" 9 | description = "The postgres password for podfetch" 10 | } 11 | 12 | variable "postgres_db" { 13 | default = "podfetch" 14 | description = "The postgres database for podfetch" 15 | } 16 | 17 | 18 | variable "traefik_toml_location" { 19 | description = "The location of the traefik.toml file" 20 | default = "/etc/traefik/traefik.toml" 21 | } 22 | 23 | variable "traefik_acme_location" { 24 | description = "The location of the acme.json file" 25 | default = "/etc/traefik/acme.json" 26 | } 27 | 28 | 29 | variable "traefik_access_log_location" { 30 | description = "The location of the access.log file" 31 | default = "/var/log/traefik/access.log" 32 | } 33 | 34 | variable "traefik_dynamic_conf_location" { 35 | description = "The location of the dynamic configuration files" 36 | default = "/etc/traefik/dynamic_conf" 37 | } 38 | 39 | variable "postgres-dir" { 40 | description = "The location of the postgres data directory" 41 | default = "/var/podfetch/db" 42 | } 43 | 44 | variable "podcast-dir" { 45 | description = "The location of the podcast directory" 46 | default = "/var/podfetch/podcasts" 47 | } 48 | 49 | 50 | variable "traefik-http-port" { 51 | description = "The port to listen on for http traffic" 52 | default = "80" 53 | } 54 | 55 | variable "traefik-https-port" { 56 | description = "The port to listen on for https traffic" 57 | default = "443" 58 | } -------------------------------------------------------------------------------- /ui/src/pages/PlaylistDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import {Heading2} from "../components/Heading2"; 2 | import {PodcastDetailItem} from "../components/PodcastDetailItem"; 3 | import {useTranslation} from "react-i18next"; 4 | import {useParams} from "react-router-dom"; 5 | import usePlaylist from "../store/PlaylistSlice"; 6 | import {useEffect} from "react"; 7 | import {PodcastInfoModal} from "../components/PodcastInfoModal"; 8 | import {PodcastEpisodeAlreadyPlayed} from "../components/PodcastEpisodeAlreadyPlayed"; 9 | import {client} from "../utils/http"; 10 | 11 | export const PlaylistDetailPage = ()=>{ 12 | const {t} = useTranslation() 13 | const params = useParams() 14 | const selectedPlaylist = usePlaylist(state=>state.selectedPlaylist) 15 | const setSelectedPlaylist = usePlaylist(state=>state.setSelectedPlaylist) 16 | 17 | 18 | useEffect(()=>{ 19 | client.GET("/api/v1/playlist/{playlist_id}", { 20 | params: { 21 | path: { 22 | playlist_id: String(params.id) 23 | } 24 | } 25 | }).then((response)=>{ 26 | setSelectedPlaylist(response.data!) 27 | }) 28 | },[]) 29 | 30 | return selectedPlaylist&&
    31 | {t('available-episodes')} 32 | 33 | 34 | {selectedPlaylist.items.map((episode, index) => { 35 | return 37 | })} 38 |
    39 | } 40 | -------------------------------------------------------------------------------- /setup/terraform/postgres-docker/setup.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | docker = { 4 | source = "kreuzwerker/docker" 5 | version = "3.0.2" 6 | } 7 | } 8 | } 9 | 10 | provider "docker" { 11 | host = "unix:///var/run/docker.sock" 12 | } 13 | 14 | module "prepare-traefik" { 15 | source = "./prepare-traefik" 16 | traefik_access_log_location = var.traefik_access_log_location 17 | traefik_acme_location = var.traefik_acme_location 18 | traefik_dynamic_conf_location = var.traefik_dynamic_conf_location 19 | traefik_toml_location = var.traefik_toml_location 20 | } 21 | 22 | module "deploy-traefik-images" { 23 | source = "./start-traefik" 24 | depends_on = [module.prepare-traefik] 25 | providers = { 26 | docker = docker 27 | } 28 | traefik_access_log_location = var.traefik_access_log_location 29 | traefik_acme_location = var.traefik_acme_location 30 | traefik_dynamic_conf_location = var.traefik_dynamic_conf_location 31 | traefik_toml_location = var.traefik_toml_location 32 | public_port = var.traefik-http-port 33 | public_port_https = var.traefik-https-port 34 | } 35 | 36 | module "deploy-postgres-db" { 37 | source = "./start-db" 38 | providers = { 39 | docker = docker 40 | } 41 | db_name = var.postgres_db 42 | db_password = var.postgres_password 43 | db_user = var.postgres_user 44 | db_dir = var.postgres-dir 45 | } 46 | 47 | module "deploy-podfetch" { 48 | source = "./start-podfetch" 49 | depends_on = [module.deploy-traefik-images, module.deploy-postgres-db] 50 | db_name = var.postgres_db 51 | db_password = var.postgres_password 52 | db_user = var.postgres_user 53 | podcast_dir = var.postgres-dir 54 | } -------------------------------------------------------------------------------- /ui/src/components/PlayerEpisodeInfo.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import useCommon, { Podcast, PodcastEpisode} from '../store/CommonSlice' 3 | import 'material-symbols/outlined.css' 4 | import {components} from "../../schema"; 5 | 6 | type PlayerEpisodeInfoProps = { 7 | podcastEpisode?: components["schemas"]["PodcastEpisodeDto"], 8 | podcast?: components["schemas"]["PodcastDto"] | undefined 9 | } 10 | 11 | export const PlayerEpisodeInfo: FC = ({ podcastEpisode, podcast }) => { 12 | const setDetailedAudioPlayerOpen = useCommon(state => state.setDetailedAudioPlayerOpen) 13 | 14 | return ( 15 |
    16 | {/* Thumbnail */} 17 |
    18 |
    {setDetailedAudioPlayerOpen(true)}}> 20 | open_in_full 21 |
    22 |
    23 | 24 | {/* Titles */} 25 |
    26 | {podcastEpisode?.name} 27 | {podcast?.name} 28 |
    29 |
    30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/models/order_criteria.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | use utoipa::ToSchema; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] 5 | pub enum OrderCriteria { 6 | #[serde(rename = "ASC")] 7 | Asc, 8 | #[serde(rename = "DESC")] 9 | Desc, 10 | } 11 | 12 | impl From for OrderCriteria { 13 | fn from(s: String) -> Self { 14 | match s.as_str() { 15 | "ASC" => OrderCriteria::Asc, 16 | "DESC" => OrderCriteria::Desc, 17 | _ => panic!("Invalid OrderCriteria"), 18 | } 19 | } 20 | } 21 | 22 | impl OrderCriteria { 23 | pub fn to_bool(&self) -> bool { 24 | match self { 25 | OrderCriteria::Asc => true, 26 | OrderCriteria::Desc => false, 27 | } 28 | } 29 | } 30 | #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] 31 | #[serde(rename_all = "UPPERCASE")] 32 | pub enum OrderOption { 33 | PublishedDate, 34 | Title, 35 | } 36 | 37 | impl Display for OrderOption { 38 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 39 | match self { 40 | OrderOption::PublishedDate => write!(f, "PublishedDate"), 41 | OrderOption::Title => write!(f, "Title"), 42 | } 43 | } 44 | } 45 | impl OrderOption { 46 | pub fn from_string(s: String) -> Self { 47 | match s.as_str() { 48 | "PUBLISHEDDATE" => OrderOption::PublishedDate, 49 | "TITLE" => OrderOption::Title, 50 | "PublishedDate" => OrderOption::PublishedDate, 51 | "Title" => OrderOption::Title, 52 | _ => { 53 | panic!("Invalid OrderOption") 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/src/HOSTING.md: -------------------------------------------------------------------------------- 1 | ## Proxy 2 | 3 | ## Requirements 4 | - Set the `SERVER_URL` environment variable to the url of the proxy. 5 | - Turn on websocket support in your proxy 6 | 7 | You won't be able to use your service via the plain local url as the websocket connection will fail. 8 | 9 | If the SERVER_URL starts with 10 | - https => Secured Websocket (wss) 11 | - http => Unsecured Websocket (ws) 12 | 13 | # Telegram 14 | 15 | PodFetch can also send messages via Telegram if a new episode was downloaded. 16 | 17 | To enable it you need to set the following variables: 18 | 19 | | Variable | Description | example | 20 | |----------------------|----------------------------------------------------------------|----------------------------------| 21 | | TELEGRAM_BOT_TOKEN | The Bot token that you can acquire from Botfather with /newbot | asdj23:hsifuhi234klerlf...sadasd | 22 | | TELEGRAM_BOT_CHAT_ID | The chat id of the chat where the bot should send the messages | 123456789 | 23 | | TELEGRAM_API_ENABLED | If the telegram api should be enabled. | true | 24 | 25 | You can acquire the Telegram Bot chat id with the following steps: 26 | 1. Write a message to the bot 27 | 2. Open the following url in your browser: [Telegram API page](https://api.telegram.org/bot/getUpdates) 28 | 3. Search for the chat id in the response 29 | 30 | 31 | # Proxying requests to the podcast servers 32 | 33 | In some cases you also need to proxy the traffic from the PodFetch server via a proxy. For that exists the `PODFETCH_PROXY` variable. You set it to the address of your proxy. --------------------------------------------------------------------------------