├── .browserslistrc ├── .dockerignore ├── .env.development ├── .env.test ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .tool-versions ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── manifest.js │ └── images │ │ ├── .keep │ │ ├── favicon │ │ ├── 16.png │ │ └── 32.png │ │ └── logo │ │ ├── navbar.png │ │ ├── square.png │ │ └── wide.png ├── channels │ ├── application_cable │ │ ├── channel.rb │ │ └── connection.rb │ ├── broadcasts │ │ ├── application_broadcast.rb │ │ └── download_status.rb │ └── download_status_channel.rb ├── controllers │ ├── application_controller.rb │ ├── application_devise_controller.rb │ ├── concerns │ │ └── .keep │ ├── feeds_controller.rb │ ├── graphql_controller.rb │ └── vue_apps_controller.rb ├── graphql │ └── user_api │ │ ├── mutations │ │ ├── .keep │ │ ├── base_mutation.rb │ │ ├── create_feed.rb │ │ ├── destroy_feed.rb │ │ ├── destroy_request.rb │ │ ├── order_feeds.rb │ │ ├── request_for_url.rb │ │ └── update_request.rb │ │ ├── podify_schema.rb │ │ └── types │ │ ├── .keep │ │ ├── base_argument.rb │ │ ├── base_enum.rb │ │ ├── base_field.rb │ │ ├── base_input_object.rb │ │ ├── base_interface.rb │ │ ├── base_object.rb │ │ ├── base_scalar.rb │ │ ├── base_union.rb │ │ ├── data_type.rb │ │ ├── download_status_type.rb │ │ ├── download_type.rb │ │ ├── feed_type.rb │ │ ├── mutation_type.rb │ │ ├── query_type.rb │ │ ├── request_type.rb │ │ └── source_type.rb ├── helpers │ └── application_helper.rb ├── javascript │ ├── components │ │ ├── app.vue │ │ ├── download_status_subscription.vue │ │ ├── feeds │ │ │ ├── add_request.vue │ │ │ ├── bookmarklet.vue │ │ │ ├── destroy.vue │ │ │ ├── has_feed_mixin.js │ │ │ ├── index.vue │ │ │ ├── list.vue │ │ │ ├── list_item.vue │ │ │ ├── new.vue │ │ │ ├── new_request_form.vue │ │ │ └── settings.vue │ │ ├── navbar.vue │ │ ├── player.vue │ │ ├── request.vue │ │ ├── requests.vue │ │ ├── requests │ │ │ ├── change_feed.vue │ │ │ └── destroy.vue │ │ ├── sidebar.vue │ │ ├── source.vue │ │ ├── source_status.vue │ │ ├── subscribe.vue │ │ └── thumbnail.vue │ ├── config.js │ ├── error-toaster.js │ ├── mutations.js │ ├── packs │ │ ├── application.js │ │ └── main.js │ ├── queries.js │ ├── router.js │ ├── store │ │ ├── feeds.js │ │ ├── index.js │ │ └── requests.js │ ├── stylesheets │ │ ├── actions.scss │ │ ├── application.scss │ │ ├── authentication.scss │ │ ├── bootstrap-customization.scss │ │ ├── bootstrap-variables.scss │ │ ├── feeds.scss │ │ ├── hamburger.scss │ │ ├── layout.scss │ │ ├── navbar.scss │ │ ├── player.scss │ │ ├── sidebar.scss │ │ ├── sources.scss │ │ ├── subscribe.scss │ │ └── thumbnail.scss │ └── utils.js ├── jobs │ ├── .keep │ ├── downloader │ │ └── fetch_source_job.rb │ ├── garbage_collection_job.rb │ └── upgrade_youtube_dl_job.rb ├── models │ ├── application_model.rb │ ├── concerns │ │ └── .keep │ ├── devise_user.rb │ ├── download.rb │ ├── feed.rb │ ├── request.rb │ ├── source.rb │ └── user.rb ├── presenters │ ├── application_presenter.rb │ ├── download_presenter.rb │ ├── download_status_presenter.rb │ ├── feed_presenter.rb │ ├── file_presenter.rb │ ├── request_presenter.rb │ └── source_presenter.rb ├── templates │ ├── feeds │ │ └── render.rss.builder │ └── layouts │ │ └── application.html.slim └── views │ ├── application_part.rb │ ├── application_view.rb │ ├── application_view_context.rb │ ├── layouts │ ├── _application.html.slim │ ├── application.html.slim │ └── devise.html.slim │ ├── parts │ └── source.rb │ ├── views │ └── feeds │ │ └── render.rb │ └── vue_apps │ └── main.html.slim ├── babel.config.js ├── bin ├── bundle ├── podify ├── rails ├── rake ├── setup ├── start-server ├── start-worker ├── webpack ├── webpack-dev-server └── yarn ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── content_security_policy.rb │ ├── cookies_serializer.rb │ ├── devise.rb │ ├── dry-view-reloading-patch.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── new_framework_defaults_7_0.rb │ ├── permissions_policy.rb │ ├── sequel.rb │ ├── shrine.rb │ ├── system.rb │ ├── wrap_parameters.rb │ └── youtube_dl.rb ├── locales │ ├── devise.en.yml │ ├── en.yml │ └── formats.en.yml ├── puma.rb ├── routes.rb ├── sidekiq.yml ├── spring.rb ├── system │ ├── event_logger.rb │ └── initial_user.rb ├── webpack │ ├── development.js │ ├── environment.js │ ├── loaders │ │ └── vue.js │ ├── production.js │ └── test.js └── webpacker.yml ├── db ├── migrate │ ├── 20200412191625_create_first_tables.rb │ ├── 20200423145735_remove_title_from_sources.rb │ ├── 20200425113118_create_users.rb │ ├── 20200501124610_create_requests.rb │ ├── 20200501175137_add_authentication_token_to_users.rb │ ├── 20200508224831_create_feeds.rb │ ├── 20200519161740_add_file_data_to_downloads.rb │ ├── 20200522123751_remove_path_from_downloads.rb │ ├── 20200522131956_add_feed_id_to_requests.rb │ └── 20200523151156_add_position_to_feeds.rb └── schema.rb ├── demo.gif ├── dev-env ├── docker-compose.yml ├── postgres │ └── init │ │ └── main.sh ├── tmuxp.yml └── up ├── docker ├── build ├── docker-compose.build.yml ├── docker-compose.example.yml ├── entrypoint.sh └── push ├── lib ├── application_contract.rb ├── assets │ └── .keep ├── cli │ ├── commands.rb │ └── commands │ │ ├── downloads │ │ └── delete.rb │ │ ├── sources │ │ ├── add.rb │ │ ├── delete.rb │ │ ├── fetch.rb │ │ ├── import.rb │ │ ├── list.rb │ │ ├── request.rb │ │ └── selection.rb │ │ └── user.rb ├── downloader │ ├── fetch_source.rb │ ├── fetcher_factory.rb │ ├── fetchers │ │ ├── file.rb │ │ └── youtube_dl.rb │ └── result.rb ├── downloads │ ├── contract.rb │ ├── create.rb │ ├── destroy.rb │ └── file_uploader.rb ├── events │ ├── logger.rb │ ├── logger │ │ └── listener.rb │ └── publisher.rb ├── expand_path.rb ├── feeds │ ├── contract.rb │ ├── create.rb │ ├── destroy.rb │ └── order.rb ├── requests │ ├── contract.rb │ ├── create.rb │ ├── create_for_url.rb │ ├── destroy.rb │ └── update.rb ├── sources │ ├── contract.rb │ ├── create.rb │ ├── destroy.rb │ ├── find_or_create.rb │ ├── import_directory.rb │ └── import_file.rb ├── tasks │ └── .keep ├── types.rb └── users │ ├── contract.rb │ ├── create.rb │ └── create_initial.rb ├── log └── .keep ├── logo ├── attributions ├── favicon.svg ├── logo.svg ├── podcast-favicon.svg └── podcast-solid.svg ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── spec ├── factories │ ├── feeds.rb │ ├── sources.rb │ └── users.rb ├── fixtures │ └── files │ │ └── example.mp4 ├── integration │ └── lib │ │ ├── requests │ │ └── create_for_url_spec.rb │ │ └── sources │ │ └── import_file_spec.rb ├── rails_helper.rb └── spec_helper.rb ├── tmp └── .keep ├── vendor └── .keep └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | /vendor/bundle 3 | /.bundle 4 | /log 5 | /tmp 6 | /public/packs 7 | /.git* 8 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://podify@localhost/podify_development 2 | REDIS_URL=redis://localhost:6379 3 | 4 | HOST=podify.localhost 5 | PORT=3000 6 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://podify:FJJuo5onaohF8rp@localhost/podify_test 2 | 3 | HOST=localhost 4 | PORT=3000 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v2 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | bundler-cache: true 21 | - name: Run rspec 22 | run: | 23 | bundle exec sequel -m db/migrate $DATABASE_URL 24 | bundle exec rspec 25 | env: 26 | DATABASE_URL: postgres://postgres:pass@localhost:5432/podify 27 | REDIS_URL: redis://localhost:6379 28 | 29 | services: 30 | postgres: 31 | image: postgres 32 | env: 33 | POSTGRES_USER: postgres 34 | POSTGRES_PASSWORD: pass 35 | POSTGRES_DB: podify 36 | ports: 37 | - 5432:5432 38 | # needed because the postgres container does not provide a healthcheck 39 | # tmpfs makes DB faster by using RAM 40 | options: >- 41 | --mount type=tmpfs,destination=/var/lib/postgresql/data 42 | --health-cmd pg_isready 43 | --health-interval 10s 44 | --health-timeout 5s 45 | --health-retries 20 46 | 47 | redis: 48 | image: redis 49 | ports: 50 | - 6379:6379 51 | 52 | push_to_registry: 53 | name: Push Docker image to Docker Hub 54 | needs: test 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Check out the repo 58 | uses: actions/checkout@v2 59 | 60 | - name: Log in to Docker Hub 61 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 62 | with: 63 | username: ${{ secrets.DOCKER_USERNAME }} 64 | password: ${{ secrets.DOCKER_PASSWORD }} 65 | 66 | # - name: Set up QEMU 67 | # uses: docker/setup-qemu-action@master 68 | # with: 69 | # platforms: all 70 | 71 | - name: Set up Docker Buildx 72 | id: buildx 73 | uses: docker/setup-buildx-action@master 74 | 75 | - name: Cache Docker layers 76 | uses: actions/cache@v2 77 | with: 78 | path: /tmp/.buildx-cache 79 | key: ${{ runner.os }}-buildx-${{ github.sha }} 80 | restore-keys: | 81 | ${{ runner.os }}-buildx- 82 | 83 | - name: Extract metadata (tags, labels) for Docker 84 | id: meta 85 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 86 | with: 87 | images: maxhollmann/podify 88 | tags: | 89 | type=ref,event=branch 90 | type=semver,pattern=v{{version}},event=tag 91 | type=semver,pattern=v{{major}},event=tag 92 | type=semver,pattern=v{{major}}.{{minor}},event=tag 93 | # type=ref,event=pr 94 | 95 | - name: Build and push Docker image 96 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 97 | with: 98 | builder: ${{ steps.buildx.outputs.name }} 99 | context: . 100 | push: true 101 | tags: ${{ steps.meta.outputs.tags }} 102 | labels: ${{ steps.meta.outputs.labels }} 103 | platforms: linux/amd64 # ,linux/arm64,linux/arm/v7 104 | cache-from: type=local,src=/tmp/.buildx-cache 105 | cache-to: type=local,dest=/tmp/.buildx-cache-new 106 | 107 | # This ugly bit is necessary if you don't want your cache to grow forever 108 | # till it hits GitHub's limit of 5GB. 109 | # Temp fix 110 | # https://github.com/docker/build-push-action/issues/252 111 | # https://github.com/moby/buildkit/issues/1896 112 | - name: Move cache 113 | run: | 114 | rm -rf /tmp/.buildx-cache 115 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 116 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | 17 | /public/assets 18 | .byebug_history 19 | 20 | # Ignore master key for decrypting credentials and more. 21 | /config/master.key 22 | 23 | /public/packs 24 | /public/packs-test 25 | /node_modules 26 | /yarn-error.log 27 | yarn-debug.log* 28 | .yarn-integrity 29 | /.env.*.local 30 | /spec/examples.txt 31 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.2.1 2 | nodejs 12.22.12 3 | yarn 1.22.10 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2.1-bullseye 2 | 3 | RUN apt-get update -qq && apt-get install -y nodejs npm postgresql-client python3 python3-pip ffmpeg 4 | RUN npm install -g yarn 5 | 6 | RUN mkdir /app 7 | WORKDIR /app 8 | 9 | COPY Gemfile /app/Gemfile 10 | COPY Gemfile.lock /app/Gemfile.lock 11 | RUN bundle config set clean true && \ 12 | bundle config set deployment true && \ 13 | bundle config set no-cache true && \ 14 | bundle config set without 'development test' && \ 15 | bundle install 16 | 17 | COPY package.json /app/package.json 18 | COPY yarn.lock /app/yarn.lock 19 | RUN yarn install --production 20 | 21 | COPY . /app 22 | 23 | RUN chmod +x /app/docker/entrypoint.sh 24 | ENTRYPOINT ["/app/docker/entrypoint.sh"] 25 | EXPOSE 3000 26 | 27 | ENV PATH="/app/bin:${PATH}" 28 | ENV RAILS_ENV=production 29 | ENV NODE_ENV=production 30 | 31 | # Precompile assets 32 | RUN webpack 33 | 34 | ENV RAILS_SERVE_STATIC_FILES="yes" 35 | ENV RAILS_LOG_TO_STDOUT="yes" 36 | ENV PORT=3000 37 | 38 | CMD ["start-server"] 39 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby '3.2.1' 5 | 6 | gem 'dotenv-rails', "~> 2.7", groups: [:development, :test], require: 'dotenv/rails-now' 7 | 8 | gem 'rails', '~> 7.0' 9 | # Use Puma as the app server 10 | gem 'puma', '~> 4.3' 11 | # Use SCSS for stylesheets 12 | gem 'sass-rails', '~> 6.0' 13 | # Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker 14 | gem 'webpacker', '~> 4.2' 15 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 16 | gem 'jbuilder', '~> 2.10' 17 | # Use Active Model has_secure_password 18 | # gem 'bcrypt', '~> 3.1.7' 19 | 20 | # prevents .../ruby/3.2.0/psych/visitors/to_ruby.rb:432:in `visit_Psych_Nodes_Alias': Alias parsing was not enabled. To enable it, pass `aliases: true` to `Psych::load` or `Psych::safe_load`. (Psych::AliasesNotEnabled) 21 | gem 'psych', '< 4' 22 | 23 | gem 'pg', "~> 1.2" 24 | gem 'sequel-rails', "~> 1.1" 25 | 26 | gem 'lograge', "~> 0.11" 27 | gem 'logstash-event', "~> 1.2" 28 | gem 'sentry-raven', "~> 3.0" 29 | 30 | gem 'sidekiq', "~> 6.0" 31 | gem 'sidekiq-scheduler', "~> 3.0" 32 | 33 | gem 'graphql', "~> 1.10" 34 | gem 'graphiql-rails', "~> 1.7" 35 | 36 | gem 'dry-rails', "~> 0.3.0" 37 | gem 'dry-monads', "~> 1.4.0" 38 | gem 'dry-view', "~> 0.7.0" 39 | gem 'dry-cli', "~> 0.7.0" 40 | gem 'dry-events', "~> 0.3.0" 41 | gem 'dry-effects', "~> 0.1.0" 42 | gem 'dry-container', "~> 0.8.0" 43 | gem 'dry-auto_inject', "~> 0.8.0" 44 | 45 | gem 'devise', "~> 4.7" 46 | gem 'sequel-devise', "~> 0.0" 47 | gem 'devise-bootstrap-views', '~> 1.1' 48 | gem 'devise-token_authenticatable', "~> 1.1" 49 | 50 | gem 'shrine', '~> 3.2' 51 | 52 | gem 'ytdl', "~> 0.2.0" 53 | 54 | gem 'slim-rails', "~> 3.2" 55 | gem 'builder', "~> 3.2" 56 | gem 'mimemagic', "~> 0.3" 57 | 58 | gem 'amazing_print', "~> 1.2" 59 | 60 | group :development, :test do 61 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 62 | gem 'byebug', "~> 11.1", platforms: [:mri, :mingw, :x64_mingw] 63 | gem 'pry-rails', "~> 0.3" 64 | 65 | gem 'rspec', "~> 3.9" 66 | gem 'rspec-rails', "~> 4.0" 67 | gem 'factory_bot_rails', "~> 5.2" 68 | end 69 | 70 | group :development do 71 | # Preview mail in the browser instead of sending 72 | gem 'letter_opener' 73 | 74 | # Debug I18n keys 75 | # gem 'i18n-debug' 76 | 77 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 78 | gem 'web-console' 79 | gem 'listen' 80 | gem 'better_errors' 81 | gem 'binding_of_caller' 82 | end 83 | 84 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 85 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podify 2 | 3 | ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/maxhollmann/podify?sort=semver) 4 | ![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/maxhollmann/podify?sort=semver) 5 | ![GitHub](https://img.shields.io/github/license/podify-org/podify) 6 | ![CI](https://github.com/podify-org/podify/workflows/CI/badge.svg) 7 | 8 | Create podcasts from anything youtube-dl can handle. 9 | 10 | ![Demo](demo.gif) 11 | 12 | Podify is a self-hosted service that allows you to download videos and audio from any source supported by [youtube-dl](https://github.com/layer8x/youtube-dl.rb), sort the downloads into feeds, and subscribe to these feeds using your favorite podcast app (I recommend [Podcast Addict](https://play.google.com/store/apps/details?id=com.bambuna.podcastaddict&hl=en) if you're on Android). 13 | 14 | ## Installation using Docker 15 | 16 | Here's a [sample `docker-compose.yml`](docker/docker-compose.example.yml). 17 | 18 | ## Configuration via Environment Variables 19 | 20 | | Variable | Required | Description | 21 | |:----------------------|:---------|:------------------------------------------------------------------------------------------------------| 22 | | DATABASE_URL | Yes | `postgres://` url pointing to postgres database | 23 | | REDIS_URL | Yes | `redis://` url pointing to redis instance | 24 | | URL_HOST | Yes | Where the app will be available, e.g. `https://podify.yourdomain.com` | 25 | | SECRET_KEY_BASE | Yes | This is used to encrypt sessions, see [below](#secret_key_base) | 26 | | STORAGE_DIR | Yes | Where downloads will be stored | 27 | | INITIAL_USER_EMAIL | No | Email of user to create at startup | 28 | | INITIAL_USER_PASSWORD | No | Password of user to create at startup | 29 | | ENABLE_SIGNUP | No | Allow anyone with access to the site to sign up. `"yes"` or `"no"` (default: `"yes"`) | 30 | 31 | ### `SECRET_KEY_BASE` 32 | 33 | This is utilized to encrypt and sign sessions. It's recommended that you generate a new one instead of using the one from the example `docker-compose.yml`. You can generate a new one using 34 | 35 | docker run --rm maxhollmann/podify rails secret 36 | 37 | ## Development 38 | 39 | ```shell 40 | docker compose -f dev-env/docker-compose.yml up 41 | bundle install 42 | yarn install 43 | 44 | bundle exec sequel -m db/migrate postgres://podify@localhost/podify_development 45 | bundle exec sequel -m db/migrate postgres://podify@localhost/podify_test 46 | 47 | bin/webpack-dev-server 48 | bundle exec rails s 49 | bundle exec sidekiq 50 | ``` 51 | 52 | If you have [tmux](https://github.com/tmux/tmux/wiki) and [tmuxp](https://tmuxp.git-pull.com/) installed you can start all required services by running `dev-env/up`. 53 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | 3 | //= link graphiql/rails/application.css 4 | //= link graphiql/rails/application.js 5 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/favicon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/assets/images/favicon/16.png -------------------------------------------------------------------------------- /app/assets/images/favicon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/assets/images/favicon/32.png -------------------------------------------------------------------------------- /app/assets/images/logo/navbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/assets/images/logo/navbar.png -------------------------------------------------------------------------------- /app/assets/images/logo/square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/assets/images/logo/square.png -------------------------------------------------------------------------------- /app/assets/images/logo/wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/assets/images/logo/wide.png -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | identified_by :current_user 4 | 5 | def connect 6 | self.current_user = helpers.current_user 7 | end 8 | 9 | private 10 | 11 | class Helpers 12 | extend Dry::Initializer 13 | option :request 14 | include Devise::Controllers::Helpers 15 | end 16 | 17 | def helpers 18 | @helpers ||= Helpers.new(request: request) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/channels/broadcasts/application_broadcast.rb: -------------------------------------------------------------------------------- 1 | module Broadcasts 2 | class ApplicationBroadcast 3 | private 4 | 5 | def server 6 | ActionCable.server 7 | end 8 | 9 | def broadcast(channel, payload) 10 | server.broadcast(channel, payload) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/channels/broadcasts/download_status.rb: -------------------------------------------------------------------------------- 1 | module Broadcasts 2 | class DownloadStatus < ApplicationBroadcast 3 | extend Dry::Initializer 4 | option :source 5 | 6 | def broadcast(status) 7 | super(channel, source_id: source.id, status: status) 8 | end 9 | 10 | def call 11 | broadcast(DownloadStatusPresenter.wrap(source).for_broadcast) 12 | end 13 | 14 | def progress(progress) 15 | broadcast({ status: 'downloading', progress: progress }) 16 | end 17 | 18 | def downloaded 19 | broadcast({ status: 'downloaded' }) 20 | end 21 | 22 | def channel 23 | "sources/#{source.id}/download_status" 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/channels/download_status_channel.rb: -------------------------------------------------------------------------------- 1 | class DownloadStatusChannel < ApplicationCable::Channel 2 | def subscribed 3 | # TODO: authorize with pandit 4 | if current_user.sources_dataset[params[:source]] 5 | stream_from "sources/#{params[:source]}/download_status" 6 | else 7 | reject 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Devise::Controllers::Helpers 3 | 4 | # append_view_path "app/templates" 5 | 6 | skip_before_action :verify_authenticity_token 7 | before_action :set_raven_context 8 | 9 | if Rails.env.development? 10 | before_action do 11 | Dry::View.reset_cache! 12 | end 13 | end 14 | 15 | private 16 | 17 | ##### dry-view helpers 18 | def render_view(identifier, with: {}, **input, &block) 19 | container["views.#{identifier}"].call( 20 | context: dry_view_context(**with), 21 | **input, 22 | &block 23 | ).to_s.html_safe 24 | end 25 | 26 | # view_context is defined by ActionView 27 | def dry_view_context(**options) 28 | container["application_view_context"].with(**view_context_options(**options)) 29 | end 30 | 31 | def view_context_options(**overrides) 32 | { 33 | request: request 34 | }.merge(overrides) 35 | end 36 | 37 | def container 38 | Podify::Container 39 | end 40 | 41 | 42 | def set_raven_context 43 | # Raven.user_context(id: session[:current_user_id]) 44 | Raven.extra_context(params: params.to_unsafe_h, url: request.url) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/controllers/application_devise_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationDeviseController < ApplicationController 2 | layout 'devise' 3 | end 4 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/feeds_controller.rb: -------------------------------------------------------------------------------- 1 | class FeedsController < ApplicationController 2 | schema(:show) do 3 | required(:token).filled(:string) 4 | end 5 | 6 | def show 7 | raise ActionController::RoutingError.new("Format not supported") unless request.format.rss? 8 | 9 | feed = Feed.with_token!(safe_params[:token]) 10 | render xml: render_view('feeds.render', feed: feed) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/graphql_controller.rb: -------------------------------------------------------------------------------- 1 | class GraphQLController < ApplicationController 2 | # If accessing from outside this domain, nullify the session 3 | # This allows for outside API access while preventing CSRF attacks, 4 | # but you'll have to authenticate your user separately 5 | # protect_from_forgery with: :null_session 6 | 7 | def execute 8 | variables = ensure_hash(params[:variables]) 9 | query = params[:query] 10 | operation_name = params[:operationName] 11 | context = { 12 | # Query context goes here, for example: 13 | current_user: current_user, 14 | } 15 | result = UserAPI::PodifySchema.execute(query, variables: variables, context: context, operation_name: operation_name) 16 | render json: result 17 | rescue => e 18 | raise e unless Rails.env.development? 19 | handle_error_in_development e 20 | end 21 | 22 | private 23 | 24 | # Handle form data, JSON body, or a blank value 25 | def ensure_hash(ambiguous_param) 26 | case ambiguous_param 27 | when String 28 | if ambiguous_param.present? 29 | ensure_hash(JSON.parse(ambiguous_param)) 30 | else 31 | {} 32 | end 33 | when Hash, ActionController::Parameters 34 | ambiguous_param 35 | when nil 36 | {} 37 | else 38 | raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" 39 | end 40 | end 41 | 42 | def handle_error_in_development(e) 43 | logger.error e.message 44 | logger.error e.backtrace.join("\n") 45 | 46 | render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/controllers/vue_apps_controller.rb: -------------------------------------------------------------------------------- 1 | class VueAppsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def main 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/graphql/user_api/mutations/.keep -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/base_mutation.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Mutations 3 | class BaseMutation < GraphQL::Schema::RelayClassicMutation 4 | argument_class Types::BaseArgument 5 | field_class Types::BaseField 6 | input_object_class Types::BaseInputObject 7 | object_class Types::BaseObject 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/create_feed.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class Mutations::CreateFeed < Mutations::BaseMutation 3 | null true 4 | 5 | argument :name, String, required: true 6 | 7 | field :feed, Types::FeedType, null: true 8 | field :errors, [String], null: false 9 | 10 | include Dry::Monads[:result] 11 | include Podify::Import[ 12 | create_feed: 'feeds.create', 13 | ] 14 | 15 | def resolve(name:) 16 | case create_feed.call(user_id: context[:current_user].id, name: name) 17 | in Success(feed) 18 | { 19 | feed: FeedPresenter.wrap(feed), 20 | errors: [], 21 | } 22 | in Failure(failure) 23 | { 24 | feed: nil, 25 | errors: [failure.to_s] 26 | } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/destroy_feed.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class Mutations::DestroyFeed < Mutations::BaseMutation 3 | null true 4 | 5 | argument :id, Integer, required: true 6 | 7 | field :errors, [String], null: false 8 | 9 | include Dry::Monads[:result] 10 | include Podify::Import[ 11 | destroy_feed: 'feeds.destroy', 12 | ] 13 | 14 | def resolve(id:) 15 | feed = context[:current_user].feeds_dataset[id] 16 | return { errors: ['Feed does not exist'] } unless feed 17 | 18 | case destroy_feed.call(feed) 19 | in Success() 20 | { 21 | errors: [], 22 | } 23 | in Failure(failure) 24 | { 25 | errors: [failure.to_s] 26 | } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/destroy_request.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class Mutations::DestroyRequest < Mutations::BaseMutation 3 | null true 4 | 5 | argument :id, Integer, required: true 6 | 7 | field :errors, [String], null: false 8 | 9 | include Dry::Monads[:result] 10 | include Podify::Import[ 11 | destroy_request: 'requests.destroy', 12 | ] 13 | 14 | def resolve(id:) 15 | request = context[:current_user].requests_dataset[id] 16 | return { errors: ['Request does not exist'] } unless request 17 | 18 | case destroy_request.call(request) 19 | in Success() 20 | { 21 | errors: [], 22 | } 23 | in Failure(failure) 24 | { 25 | errors: [failure.to_s] 26 | } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/order_feeds.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class Mutations::OrderFeeds < Mutations::BaseMutation 3 | null true 4 | 5 | argument :feed_ids, [Integer], required: true 6 | 7 | field :feeds, [Types::FeedType], null: false 8 | 9 | include Podify::Import[ 10 | order_feeds: 'feeds.order', 11 | ] 12 | 13 | def resolve(feed_ids:) 14 | feeds = order_feeds.call(user: context[:current_user], feed_ids: feed_ids).value! 15 | { feeds: FeedPresenter.wrap_all(feeds) } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/request_for_url.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class Mutations::RequestForUrl < Mutations::BaseMutation 3 | null true 4 | 5 | argument :url, String, required: true 6 | argument :feed_id, Integer, required: true 7 | 8 | field :request, Types::RequestType, null: true 9 | field :errors, [String], null: false 10 | 11 | include Dry::Monads[:result] 12 | include Dry::Effects::Handler.Defer 13 | include Podify::Import[ 14 | create_request_for_url: 'requests.create_for_url', 15 | ] 16 | 17 | def resolve(url:, feed_id:) 18 | feed = context[:current_user].feeds_dataset[feed_id] 19 | if feed.nil? 20 | return { 21 | request: nil, 22 | errors: ['Feed does not exist'], 23 | } 24 | end 25 | 26 | result = with_defer do 27 | create_request_for_url.call(user: context[:current_user], feed: feed, url: url) 28 | end 29 | 30 | case result 31 | in Success(request) 32 | { 33 | request: RequestPresenter.wrap(request), 34 | errors: [], 35 | } 36 | in Failure(failure) 37 | { 38 | request: nil, 39 | errors: [failure.to_s], 40 | } 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/graphql/user_api/mutations/update_request.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class Mutations::UpdateRequest < Mutations::BaseMutation 3 | null true 4 | 5 | argument :id, Integer, required: true 6 | argument :feed_id, Integer, required: true 7 | 8 | field :errors, [String], null: false 9 | field :request, Types::RequestType, null: true 10 | 11 | include Dry::Monads[:result] 12 | include Podify::Import[ 13 | update_request: 'requests.update', 14 | ] 15 | 16 | def resolve(id:, feed_id:) 17 | request = context[:current_user].requests_dataset[id] 18 | return { errors: ['Request does not exist'] } unless request 19 | 20 | case update_request.call(request, feed_id: feed_id) 21 | in Success() 22 | { 23 | errors: [], 24 | request: RequestPresenter.wrap(request), 25 | } 26 | in Failure(failure) 27 | { 28 | errors: [failure.to_s], 29 | } 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/graphql/user_api/podify_schema.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | class PodifySchema < GraphQL::Schema 3 | mutation(Types::MutationType) 4 | query(Types::QueryType) 5 | 6 | # Opt in to the new runtime (default in future graphql-ruby versions) 7 | use GraphQL::Execution::Interpreter 8 | use GraphQL::Analysis::AST 9 | 10 | # Add built-in connections for pagination 11 | use GraphQL::Pagination::Connections 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/graphql/user_api/types/.keep -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_argument.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseArgument < GraphQL::Schema::Argument 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_enum.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseEnum < GraphQL::Schema::Enum 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_field.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseField < GraphQL::Schema::Field 4 | argument_class Types::BaseArgument 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_input_object.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseInputObject < GraphQL::Schema::InputObject 4 | argument_class Types::BaseArgument 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_interface.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | module BaseInterface 4 | include GraphQL::Schema::Interface 5 | 6 | field_class Types::BaseField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_object.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseObject < GraphQL::Schema::Object 4 | field_class Types::BaseField 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_scalar.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseScalar < GraphQL::Schema::Scalar 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/base_union.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class BaseUnion < GraphQL::Schema::Union 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/data_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class DataType < Types::BaseObject 4 | field :requests, [RequestType], null: false 5 | field :feeds, [FeedType], null: false 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/download_status_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class DownloadStatusType < Types::BaseObject 4 | field :status, String, null: false 5 | field :place_in_queue, String, null: true 6 | field :retry_status, String, null: true 7 | field :progress, Integer, null: true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/download_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class DownloadType < Types::BaseObject 4 | field :id, Integer, null: false 5 | field :title, String, null: false 6 | field :author, String, null: true 7 | field :thumbnail_url, String, null: true 8 | field :format, String, null: true 9 | field :size, String, null: true, method: :human_size 10 | field :download_url, String, null: true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/feed_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class FeedType < Types::BaseObject 4 | field :id, Integer, null: false 5 | field :token, String, null: false 6 | field :name, String, null: false 7 | field :type, String, null: false 8 | field :rss_url, String, null: false 9 | field :requests, [Types::RequestType], null: false 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/mutation_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class MutationType < Types::BaseObject 4 | field :request_for_url, mutation: Mutations::RequestForUrl 5 | field :update_request, mutation: Mutations::UpdateRequest 6 | field :destroy_request, mutation: Mutations::DestroyRequest 7 | field :create_feed, mutation: Mutations::CreateFeed 8 | field :destroy_feed, mutation: Mutations::DestroyFeed 9 | field :order_feeds, mutation: Mutations::OrderFeeds 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/query_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class QueryType < Types::BaseObject 4 | field :data, DataType, description: 'All requests of the current user', null: false 5 | def data 6 | OpenStruct.new( 7 | requests: RequestPresenter.wrap_all( 8 | context[:current_user].requests_dataset.order { created_at.desc }.all 9 | ), 10 | feeds: FeedPresenter.wrap_all( 11 | context[:current_user].feeds_dataset.ordered.all 12 | ), 13 | ) 14 | end 15 | 16 | field :requests, [RequestType], description: 'All requests of the current user', null: false 17 | def requests 18 | RequestPresenter.wrap_all( 19 | context[:current_user].requests_dataset.order { created_at.desc }.all 20 | ) 21 | end 22 | 23 | field :source, SourceType, description: 'A source requested by the user', null: false do 24 | argument :id, Integer, required: true 25 | end 26 | def source(id:) 27 | SourcePresenter.wrap(context[:current_user].sources_dataset.with_pk!(id)) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/request_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class RequestType < Types::BaseObject 4 | field :id, Integer, null: false 5 | field :source, Types::SourceType, null: true 6 | field :feed_id, Integer, null: false 7 | field :feed, Types::FeedType, null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/graphql/user_api/types/source_type.rb: -------------------------------------------------------------------------------- 1 | module UserAPI 2 | module Types 3 | class SourceType < Types::BaseObject 4 | field :id, Integer, null: false 5 | field :url, String, null: false 6 | field :title, String, null: true 7 | field :author, String, null: true 8 | field :description, String, null: true 9 | field :thumbnail_url, String, null: true 10 | 11 | field :download_status, Types::DownloadStatusType, null: true 12 | field :last_download, Types::DownloadType, null: true 13 | field :downloads, [Types::DownloadType], null: false 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/javascript/components/app.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 54 | 55 | 61 | -------------------------------------------------------------------------------- /app/javascript/components/download_status_subscription.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 76 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/add_request.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 25 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/bookmarklet.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | 21 | 43 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/destroy.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 45 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/has_feed_mixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | feed() { 4 | return this.$store.getters.feedById(this.$route.params.feedId); 5 | }, 6 | feedRoute() { 7 | return { name: 'feedIndex', params: { feedId: this.feed.id } } 8 | }, 9 | }, 10 | methods: { 11 | addRequest(url) { 12 | this.$store.dispatch('createRequest', { 13 | apollo: this.$apollo, 14 | url: url, 15 | feedId: parseInt(this.feed.id), 16 | }).catch(this.$errorToaster.handler()); 17 | }, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 35 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/list.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 45 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/list_item.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 50 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/new.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 74 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/new_request_form.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 51 | 52 | 54 | -------------------------------------------------------------------------------- /app/javascript/components/feeds/settings.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 35 | -------------------------------------------------------------------------------- /app/javascript/components/navbar.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | -------------------------------------------------------------------------------- /app/javascript/components/player.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 127 | 128 | 130 | -------------------------------------------------------------------------------- /app/javascript/components/request.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 47 | -------------------------------------------------------------------------------- /app/javascript/components/requests.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /app/javascript/components/requests/change_feed.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 50 | 51 | 53 | -------------------------------------------------------------------------------- /app/javascript/components/requests/destroy.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 26 | 27 | 29 | -------------------------------------------------------------------------------- /app/javascript/components/sidebar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /app/javascript/components/source.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 71 | -------------------------------------------------------------------------------- /app/javascript/components/source_status.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 43 | -------------------------------------------------------------------------------- /app/javascript/components/subscribe.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 66 | -------------------------------------------------------------------------------- /app/javascript/components/thumbnail.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 41 | -------------------------------------------------------------------------------- /app/javascript/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | rootUrl: document.querySelector('meta[name="root-url"]').content.replace(/\/$/, ''), 3 | } 4 | -------------------------------------------------------------------------------- /app/javascript/error-toaster.js: -------------------------------------------------------------------------------- 1 | const PROP_NAME = '$errorToaster'; 2 | const PROP_NAME_PRIV = '_errorToaster'; 3 | 4 | class ErrorToaster { 5 | constructor(vm, defaultOptions = {}) { 6 | this.vm = vm; 7 | 8 | this.defaultOptions = { 9 | title: 'Error', 10 | toaster: 'b-toaster-bottom-right', 11 | appendToast: true, 12 | variant: 'danger', 13 | solid: true, 14 | autoHideDelay: 5000, 15 | ...defaultOptions 16 | }; 17 | }; 18 | 19 | handle(payload, options = {}) { 20 | console.warn(payload); 21 | 22 | let message; 23 | 24 | if (Array.isArray(payload)) 25 | message = payload.map(item => item.toString()).join(", "); 26 | else if (payload.hasOwnProperty('graphQLErrors')) 27 | message = payload.graphQLErrors.map(item => item.message).join(", "); 28 | else 29 | message = payload.toString(); 30 | 31 | this.vm.$bvToast.toast(message, { 32 | ...this.defaultOptions, 33 | ...options, 34 | }); 35 | }; 36 | 37 | handler(options = {}) { 38 | return errors => this.handle(errors, options); 39 | } 40 | } 41 | 42 | export default (Vue, options) => { 43 | Vue.mixin({ 44 | beforeCreate() { 45 | this._errorToaster = new ErrorToaster(this); 46 | }, 47 | }); 48 | 49 | Object.defineProperty(Vue.prototype, '$errorToaster', { 50 | get() { 51 | if (!this || !this._errorToaster) { 52 | console.error("$errorToaster must be accessed from a Vue instance 'this' context."); 53 | } 54 | return this._errorToaster; 55 | } 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /app/javascript/mutations.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import { queryPartials } from 'queries'; 3 | 4 | export default { 5 | requestForUrl: gql` 6 | mutation request_for_url($url: String!, $feedId: Int!) { 7 | requestForUrl(input: {url: $url, feedId: $feedId}) { 8 | request { 9 | ${queryPartials.fullRequest} 10 | } 11 | errors 12 | } 13 | } 14 | `, 15 | destroyRequest: gql` 16 | mutation destroy_request($id: Int!) { 17 | destroyRequest(input: {id: $id}) { 18 | errors 19 | } 20 | } 21 | `, 22 | updateRequest: gql` 23 | mutation update_request($id: Int!, $feedId: Int!) { 24 | updateRequest(input: {id: $id, feedId: $feedId}) { 25 | request { 26 | ${queryPartials.fullRequest} 27 | } 28 | errors 29 | } 30 | } 31 | `, 32 | createFeed: gql` 33 | mutation create_feed($name: String!) { 34 | createFeed(input: {name: $name}) { 35 | feed { 36 | ${queryPartials.fullFeed} 37 | } 38 | errors 39 | } 40 | } 41 | `, 42 | orderFeeds: gql` 43 | mutation order_feed($feedIds: [Int!]) { 44 | orderFeeds(input: {feedIds: $feedIds}) { 45 | feeds { 46 | ${queryPartials.fullFeed} 47 | } 48 | } 49 | } 50 | `, 51 | destroyFeed: gql` 52 | mutation destroy_feed($id: Int!) { 53 | destroyFeed(input: {id: $id}) { 54 | errors 55 | } 56 | } 57 | `, 58 | }; 59 | -------------------------------------------------------------------------------- /app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This file is automatically compiled by Webpack, along with any other files 2 | // present in this directory. You're encouraged to place your actual application logic in 3 | // a relevant structure within app/javascript and only use these pack files to reference 4 | // that code so it'll be compiled. 5 | 6 | import 'stylesheets/application'; 7 | import 'bootstrap'; 8 | 9 | require("@rails/ujs").start(); 10 | require.context('../../assets/images', true); 11 | 12 | 13 | // Uncomment to copy all static images under ../images to the output folder and reference 14 | // them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>) 15 | // or the `imagePath` JavaScript helper below. 16 | // 17 | // const images = require.context('../images', true) 18 | // const imagePath = (name) => images(name, true) 19 | -------------------------------------------------------------------------------- /app/javascript/packs/main.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | // Run this example by adding <%= javascript_pack_tag 'main' %> (and 3 | // <%= stylesheet_pack_tag 'main' %> if you have styles in your component) 4 | // to the head of your layout file, 5 | // like app/views/layouts/application.html.erb. 6 | // All it does is render
Hello Vue
at the bottom of the page. 7 | 8 | import 'es6-promise/auto'; 9 | 10 | import Vue from 'vue'; 11 | import ActionCableVue from 'actioncable-vue'; 12 | import App from 'components/app'; 13 | 14 | import ApolloClient from "apollo-client"; 15 | import { HttpLink } from "apollo-link-http"; 16 | import { InMemoryCache } from "apollo-cache-inmemory"; 17 | 18 | import VueApollo from "vue-apollo"; 19 | 20 | const apiUrl = document.querySelector('meta[name="api-url"]').content; 21 | const actionCableUrl = document.querySelector('meta[name="action-cable-url"]').content; 22 | 23 | Vue.use(VueApollo); 24 | Vue.use(ActionCableVue, { 25 | debug: true, 26 | debugLevel: 'error', 27 | connectionUrl: actionCableUrl, 28 | connectImmediately: true, 29 | }); 30 | 31 | import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'; 32 | Vue.use(BootstrapVue); 33 | Vue.use(IconsPlugin); 34 | 35 | import VueClipboard from 'vue-clipboard2'; 36 | VueClipboard.config.autoSetContainer = true; 37 | Vue.use(VueClipboard); 38 | 39 | import VueQrcode from '@chenfengyuan/vue-qrcode'; 40 | Vue.component(VueQrcode.name, VueQrcode); 41 | 42 | // Vue.config.productionTip = false; 43 | 44 | const getHeaders = () => { 45 | const headers = {}; 46 | const token = window.localStorage.getItem('apollo-token'); 47 | if (token) { 48 | headers.authorization = `Bearer ${token}`; 49 | } 50 | return headers; 51 | }; 52 | 53 | // Create an http link: 54 | const link = new HttpLink({ 55 | uri: apiUrl, 56 | fetch, 57 | headers: getHeaders() 58 | }); 59 | 60 | const cache = new InMemoryCache({ 61 | addTypename: true 62 | }); 63 | 64 | const client = new ApolloClient({ 65 | link, 66 | cache, 67 | }); 68 | 69 | const apolloProvider = new VueApollo({ 70 | defaultClient: client, 71 | }); 72 | 73 | Vue.directive('visible', (el, binding) => { 74 | el.style.visibility = !!binding.value ? 'visible' : 'hidden'; 75 | }); 76 | 77 | import store from 'store/index'; 78 | 79 | import VueRouter from 'vue-router'; 80 | import router from 'router'; 81 | Vue.use(VueRouter); 82 | 83 | import ErrorToaster from 'error-toaster'; 84 | Vue.use(ErrorToaster); 85 | 86 | document.addEventListener('DOMContentLoaded', () => { 87 | const app = new Vue({ 88 | apolloProvider, 89 | router, 90 | store, 91 | render: h => h(App), 92 | }).$mount("#main"); 93 | }); 94 | -------------------------------------------------------------------------------- /app/javascript/queries.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | const fullSource = ` 4 | id 5 | url 6 | title 7 | author 8 | description 9 | thumbnailUrl 10 | downloadStatus { 11 | status 12 | placeInQueue 13 | retryStatus 14 | progress 15 | } 16 | lastDownload { 17 | id 18 | title 19 | author 20 | thumbnailUrl 21 | format 22 | size 23 | downloadUrl 24 | } 25 | `; 26 | 27 | const fullRequest = ` 28 | id 29 | feedId 30 | source { 31 | ${fullSource} 32 | } 33 | `; 34 | 35 | const fullFeed = ` 36 | id 37 | token 38 | name 39 | rssUrl 40 | type 41 | `; 42 | 43 | export const queryPartials = { 44 | fullSource, 45 | fullRequest, 46 | fullFeed, 47 | }; 48 | 49 | export default { 50 | data: gql` 51 | query { 52 | data { 53 | requests { 54 | ${fullRequest} 55 | } 56 | feeds { 57 | ${fullFeed} 58 | } 59 | } 60 | } 61 | `, 62 | source: gql` 63 | query source($id: Int!) { 64 | source(id: $id) { 65 | ${fullSource} 66 | } 67 | } 68 | `, 69 | }; 70 | -------------------------------------------------------------------------------- /app/javascript/router.js: -------------------------------------------------------------------------------- 1 | import VueRouter from 'vue-router'; 2 | 3 | import FeedIndex from 'components/feeds/index'; 4 | import FeedSettings from 'components/feeds/settings'; 5 | import AddRequest from 'components/feeds/add_request'; 6 | 7 | const routes = [ 8 | { 9 | path: '/feeds/:feedId', 10 | name: 'feedIndex', 11 | component: FeedIndex, 12 | children: [ 13 | { 14 | path: 'add', 15 | name: 'addRequest', 16 | component: AddRequest, 17 | }, 18 | { 19 | path: 'settings', 20 | name: 'feedSettings', 21 | component: FeedSettings, 22 | }, 23 | ], 24 | }, 25 | ]; 26 | 27 | // 3. Create the router instance and pass the `routes` option 28 | // You can pass in additional options here, but let's 29 | // keep it simple for now. 30 | export default new VueRouter({ 31 | routes, 32 | mode: 'history', 33 | }); 34 | -------------------------------------------------------------------------------- /app/javascript/store/feeds.js: -------------------------------------------------------------------------------- 1 | import mutations from 'mutations'; 2 | 3 | export default { 4 | // TODO: enable strict mode, only in development (https://vuex.vuejs.org/guide/strict.html) 5 | // strict: true, 6 | state: { 7 | all: [], 8 | }, 9 | 10 | getters: { 11 | allFeeds(state) { 12 | return state.all; 13 | }, 14 | feedById(state) { 15 | return id => state.all.find(feed => feed.id == parseInt(id)); 16 | }, 17 | defaultFeed(state) { 18 | return state.all[0]; 19 | }, 20 | }, 21 | 22 | mutations: { 23 | setFeeds(state, { feeds }) { 24 | state.all = feeds; 25 | }, 26 | addFeed(state, { feed }) { 27 | state.all.push(feed); 28 | }, 29 | removeFeed(state, { id }) { 30 | state.all.push(feed); 31 | }, 32 | removeFeed(state, { id }) { 33 | const index = state.all.findIndex(feed => feed.id == id); 34 | state.all.splice(index, 1); 35 | }, 36 | }, 37 | 38 | actions: { 39 | createFeed({ commit }, { apollo, feed }) { 40 | return new Promise((resolve, reject) => { 41 | apollo.mutate({ 42 | mutation: mutations.createFeed, 43 | variables: feed, 44 | }).then(({ data: { createFeed: { feed, errors } } }) => { 45 | if (errors.length > 0) { 46 | reject(errors); 47 | } else { 48 | commit('addFeed', { feed }); 49 | resolve(); 50 | } 51 | }); 52 | }); 53 | }, 54 | 55 | destroyFeed({ commit, getters }, { apollo, feed }) { 56 | return new Promise((resolve, reject) => { 57 | apollo.mutate({ 58 | mutation: mutations.destroyFeed, 59 | variables: { id: feed.id }, 60 | }).then(({ data: { destroyFeed: { errors } } }) => { 61 | if (errors.length > 0) { 62 | reject(errors); 63 | } else { 64 | commit('removeFeed', { id: feed.id }); 65 | getters.requestsForFeed(feed).map(request => commit('removeRequest', { id: request.id })); 66 | resolve(); 67 | } 68 | }); 69 | }); 70 | }, 71 | 72 | orderFeeds({ commit }, { apollo, feedIds }) { 73 | return new Promise((resolve, reject) => { 74 | apollo.mutate({ 75 | mutation: mutations.orderFeeds, 76 | variables: { feedIds }, 77 | }).then(({ data: { orderFeeds: { feeds } } }) => { 78 | commit('setFeeds', { feeds }); 79 | resolve(); 80 | }); 81 | }); 82 | } 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /app/javascript/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | Vue.use(Vuex); 5 | 6 | import queries from 'queries'; 7 | import mutations from 'mutations'; 8 | 9 | import feeds from 'store/feeds'; 10 | import requests from 'store/requests'; 11 | 12 | export default new Vuex.Store({ 13 | // TODO: enable strict mode, only in development (https://vuex.vuejs.org/guide/strict.html) 14 | // strict: true, 15 | state: { 16 | loading: true, 17 | }, 18 | 19 | modules: { 20 | feeds, 21 | requests, 22 | }, 23 | 24 | mutations: { 25 | loadingState(state, payload) { 26 | state.loading = payload.state; 27 | }, 28 | }, 29 | 30 | actions: { 31 | fetchData({ commit }, apollo) { 32 | commit('loadingState', { state: true }); 33 | 34 | return new Promise((resolve, reject) => { 35 | apollo.query({ 36 | query: queries.data, 37 | }).then(({ data: { data: { requests, feeds } } }) => { 38 | commit('setFeeds', { feeds }); 39 | commit('setRequests', { requests }); 40 | commit('loadingState', { state: false }); 41 | resolve(); 42 | }).catch(reject); 43 | }); 44 | }, 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/actions.scss: -------------------------------------------------------------------------------- 1 | .actions { 2 | display: flex; 3 | 4 | position: absolute; 5 | 6 | &.top { top: .5em; } 7 | &.bottom { bottom: .5em; } 8 | &.right { right: .5em; } 9 | &.left { left: .5em; } 10 | 11 | .action { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | margin-left: 5px; 16 | float: none; 17 | color: white; 18 | width: 30px; 19 | height: 30px; 20 | border: none; 21 | border-radius: 3px; 22 | outline: none; 23 | 24 | background-color: rgba(0, 0, 0, 0.6); 25 | &:hover { 26 | background-color: $brand-orange; 27 | opacity: 1; 28 | } 29 | 30 | a { 31 | color: white; 32 | outline: none; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap-variables.scss"; 2 | @import "bootstrap-customization.scss"; 3 | @import "~bootstrap/scss/bootstrap.scss"; 4 | @import "~bootstrap-vue/src/index.scss"; 5 | @import 'vue-loading-overlay/dist/vue-loading.css'; 6 | @import "~plyr/src/sass/plyr.scss"; 7 | 8 | $fa-font-path: '~@fortawesome/fontawesome-free/webfonts'; 9 | @import '~@fortawesome/fontawesome-free/scss/fontawesome'; 10 | @import '~@fortawesome/fontawesome-free/scss/solid'; 11 | // @import '~@fortawesome/fontawesome-free/scss/regular'; 12 | // @import '~@fortawesome/fontawesome-free/scss/brands'; 13 | 14 | @import "layout.scss"; 15 | @import "authentication.scss"; 16 | @import "actions.scss"; 17 | @import "navbar.scss"; 18 | @import "sidebar.scss"; 19 | @import "sources.scss"; 20 | @import "thumbnail.scss"; 21 | @import "subscribe.scss"; 22 | @import "hamburger.scss"; 23 | @import "player.scss"; 24 | @import "feeds.scss"; 25 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/authentication.scss: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | .body-authentication { 6 | background-color: $dark; 7 | 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | height: 100%; 12 | } 13 | 14 | .authentication { 15 | max-width: 500px; 16 | padding: 1em; 17 | color: #FFF; 18 | 19 | .logo { 20 | margin-bottom: 3em; 21 | 22 | img { 23 | max-width: 100%; 24 | } 25 | } 26 | 27 | .form { 28 | max-width: 300px; 29 | margin: 0 auto; 30 | } 31 | 32 | a { 33 | color: #FFFFFF; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/bootstrap-customization.scss: -------------------------------------------------------------------------------- 1 | $brand-orange: #F06959; 2 | $dark: #222B3B; 3 | /* $primary: #F06959; */ 4 | /* $secondary: #858A93; */ 5 | /* #6B717A; */ 6 | /* #222B3B; */ 7 | /* #8C9298; */ 8 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/bootstrap-variables.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/javascript/stylesheets/bootstrap-variables.scss -------------------------------------------------------------------------------- /app/javascript/stylesheets/feeds.scss: -------------------------------------------------------------------------------- 1 | .new-feed-open-form { 2 | text-align: center; 3 | cursor: pointer; 4 | } 5 | 6 | .feeds-list-item { 7 | display: flex; 8 | 9 | // Set explicitly because sortable.js wraps it in a div with a different background 10 | background-color: $sidebar-bg; 11 | 12 | .feed-name { 13 | flex-grow: 1; 14 | white-space: nowrap; 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | 18 | text-decoration: none; 19 | outline: none; 20 | 21 | &.router-link-active { 22 | font-weight: bold; 23 | } 24 | } 25 | 26 | &:hover .feed-actions { display: inline; } 27 | .feed-actions { 28 | flex-grow: 0; 29 | display: none; 30 | white-space: nowrap; 31 | 32 | .feed-action { 33 | @extend .px-2; 34 | @extend .py-1; 35 | cursor: pointer; 36 | 37 | opacity: 0.5; 38 | &:hover { opacity: 1; } 39 | 40 | outline: none; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/hamburger.scss: -------------------------------------------------------------------------------- 1 | $hamburger-padding-x : 22px; 2 | $hamburger-padding-y : 24px; 3 | $hamburger-layer-color : #FFF; 4 | $hamburger-layer-width : 30px !default; 5 | $hamburger-layer-height : 2px !default; 6 | $hamburger-layer-spacing : 6px !default; 7 | $hamburger-layer-border-radius : 4px !default; 8 | $hamburger-hover-opacity : 0.7 !default; 9 | $hamburger-active-layer-color : $hamburger-layer-color !default; 10 | $hamburger-active-hover-opacity: $hamburger-hover-opacity; 11 | 12 | @import "~hamburgers/_sass/hamburgers/hamburgers.scss"; 13 | 14 | .navbar-toggler { 15 | position: absolute; 16 | 17 | left: 0; 18 | top: 0; 19 | @include media-breakpoint-down(sm) { 20 | top: -7px; 21 | } 22 | 23 | &:focus { outline: none; } 24 | } 25 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/layout.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #F5F5F5; 3 | } 4 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/navbar.scss: -------------------------------------------------------------------------------- 1 | $navbar-height-sm: 50px; 2 | $navbar-height-lg: 65px; 3 | 4 | .navbar { 5 | @extend .mb-3; 6 | 7 | display: flex; 8 | justify-content: center; 9 | align-content: center; 10 | 11 | height: $navbar-height-lg; 12 | @include media-breakpoint-down(sm) { height: $navbar-height-sm; } 13 | 14 | box-shadow: 0px 2px 3px #000; 15 | 16 | .logo { 17 | height: 100%; 18 | justify-self: center; 19 | align-self: center; 20 | } 21 | } 22 | 23 | .body-app { 24 | padding-top: $navbar-height-lg + 15px; 25 | @include media-breakpoint-down(sm) { padding-top: $navbar-height-sm + 15px; } 26 | } 27 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/player.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --plyr-color-main: #F06959; 3 | } 4 | 5 | $player-small-width: 320px; 6 | 7 | .player-container { 8 | visibility: hidden; 9 | 10 | position: fixed; 11 | z-index: 999999; 12 | 13 | &.small { 14 | bottom: 20px; 15 | right: 20px; 16 | width: $player-small-width; 17 | height: 10/16 * $player-small-width; 18 | } 19 | &.big { 20 | top: 50%; 21 | left: 50%; 22 | margin-left: -35%; 23 | margin-top: 0; 24 | 25 | height: 53px; // will be adjusted via JS 26 | 27 | width: 100%; 28 | max-width: 1150px; 29 | } 30 | 31 | .actions { display: none; } 32 | &:hover .actions { display: flex; } 33 | } 34 | 35 | .player-backdrop { 36 | z-index: 999998; 37 | position: fixed; 38 | top: 0; 39 | left: 0; 40 | bottom: 0; 41 | right: 0; 42 | background: #000000; 43 | opacity: 0.8; 44 | } 45 | 46 | .body-app { 47 | padding-bottom: 180px; 48 | } 49 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/sidebar.scss: -------------------------------------------------------------------------------- 1 | $sidebar-width: 300px; 2 | $sidebar-bg: lighten($dark, 6%); 3 | 4 | .sidebar { 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: space-between; 8 | 9 | position: fixed; 10 | top: 0; 11 | left: 0; 12 | z-index: 1000; 13 | background-color: $sidebar-bg; 14 | 15 | color: #FFF; 16 | a { 17 | color: #FFF; 18 | } 19 | 20 | height: 100%; 21 | 22 | padding-top: $navbar-height-lg + 15px; 23 | @include media-breakpoint-down(sm) { padding-top: $navbar-height-sm + 15px; } 24 | 25 | padding-bottom: 15px; 26 | 27 | width: $sidebar-width; 28 | 29 | left: -$sidebar-width; 30 | transition: left 0.3s; 31 | &.active { 32 | left: 0; 33 | box-shadow: 2px 0px 15px #000; 34 | } 35 | 36 | .nav { 37 | display: block; 38 | width: 100%; 39 | } 40 | .top { 41 | align-self: start; 42 | } 43 | .bottom { 44 | align-self: end; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/sources.scss: -------------------------------------------------------------------------------- 1 | .source-wrapper { 2 | @extend .col-sm-6; 3 | @extend .col-md-4; 4 | @extend .col-lg-3; 5 | } 6 | 7 | .source { 8 | @extend .mb-4; 9 | border: none; 10 | background-color: inherit; 11 | 12 | .source-title { 13 | font-size: 1rem; 14 | font-weight: bold; 15 | 16 | max-height: 2.4rem; 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | 20 | display: -webkit-box; 21 | -webkit-box-orient: vertical; 22 | white-space: normal; 23 | -webkit-line-clamp: 2; 24 | } 25 | 26 | .source-author { 27 | font-size: .9rem; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/subscribe.scss: -------------------------------------------------------------------------------- 1 | .big-subscribe .subscribe { 2 | margin-top: 3rem; 3 | margin-bottom: 2rem; 4 | display: flex; 5 | justify-content: center; 6 | width: 100%; 7 | 8 | .subscribe-icon { 9 | cursor: pointer; 10 | font-size: 4rem; 11 | margin: 1rem; 12 | outline: none; 13 | 14 | color: lighten($secondary, 30%); 15 | &:hover { color: $secondary; } 16 | } 17 | } 18 | 19 | .subscribe-modal { 20 | .qrcode { 21 | display: block; 22 | margin: 0 auto; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/javascript/stylesheets/thumbnail.scss: -------------------------------------------------------------------------------- 1 | .source-thumbnail-wrapper { 2 | @extend .mb-2; 3 | 4 | position: relative; 5 | width: 100%; 6 | background-color: #000000; 7 | 8 | @include media-breakpoint-down(lg) { height: 118px; } 9 | @include media-breakpoint-down(sm) { height: 135px; } 10 | @include media-breakpoint-down(xs) { height: 170px; } 11 | height: 144px; 12 | 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | 17 | .source-thumbnail { 18 | max-width: 100%; 19 | max-height: 100%; 20 | 21 | &.placeholder { 22 | width: 100%; 23 | height: 100%; 24 | } 25 | } 26 | 27 | .placeholder-icon { 28 | color: $brand-orange; 29 | position: absolute; 30 | margin: auto; 31 | font-size: 6rem; 32 | } 33 | 34 | .actions { display: none; } 35 | &:hover .actions { display: flex; } 36 | } 37 | -------------------------------------------------------------------------------- /app/javascript/utils.js: -------------------------------------------------------------------------------- 1 | let _currentOptimisticId = 2147480000; 2 | export function optimisticId() { 3 | return _currentOptimisticId++; 4 | } 5 | -------------------------------------------------------------------------------- /app/jobs/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/jobs/.keep -------------------------------------------------------------------------------- /app/jobs/downloader/fetch_source_job.rb: -------------------------------------------------------------------------------- 1 | module Downloader 2 | class FetchSourceJob 3 | include Sidekiq::Worker 4 | include Dry::Monads[:result] 5 | include Dry::Effects::Handler.Resolve 6 | 7 | include Podify::Import['downloader.fetch_source'] 8 | 9 | def perform(source_id) 10 | source = Source[source_id] 11 | if source.nil? 12 | logger.error "Source #{source_id} doesn't exist" 13 | return 14 | end 15 | 16 | provide('downloader.fetch_source.progress_callback' => ProgressCallback.new(source)) do 17 | fetch_source.call(source).or do |failure| 18 | ap failure 19 | 20 | case failure 21 | when Dry::Validation::Result 22 | Rails.logger.error failure.errors.to_h 23 | when 'downloads.create.path_exists' 24 | broadcast.call 25 | # already fetched, all good 26 | when 'downloader.fetcher_factory.no_fetcher_for_scheme' 27 | # no need to retry 28 | else 29 | Rails.logger.error failure 30 | raise "Failed (#{failure})" 31 | end 32 | end 33 | end 34 | rescue StandardError => e 35 | puts e 36 | puts e.backtrace 37 | raise 38 | end 39 | 40 | class ProgressCallback 41 | PROGRESS_THROTTLE = 0.2.seconds 42 | 43 | extend Dry::Initializer 44 | param :source 45 | 46 | def start_download 47 | progress(0) 48 | end 49 | 50 | def progress(progress) 51 | return if @last_progress_broadcast && @last_progress_broadcast > PROGRESS_THROTTLE.ago 52 | 53 | source.download_progress = progress 54 | broadcast.progress(progress) 55 | @last_progress_broadcast = Time.now 56 | end 57 | 58 | def complete 59 | broadcast.call 60 | end 61 | 62 | private 63 | 64 | def broadcast 65 | @broadcast ||= Broadcasts::DownloadStatus.new(source: source) 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /app/jobs/garbage_collection_job.rb: -------------------------------------------------------------------------------- 1 | class GarbageCollectionJob 2 | include Sidekiq::Worker 3 | 4 | sidekiq_options retry: false 5 | 6 | include Podify::Import[ 7 | destroy_source: 'sources.destroy', 8 | ] 9 | 10 | def perform 11 | Source.without_requests.paged_each do |source| 12 | destroy_source.call(source) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/jobs/upgrade_youtube_dl_job.rb: -------------------------------------------------------------------------------- 1 | class UpgradeYoutubeDlJob 2 | include Sidekiq::Worker 3 | 4 | sidekiq_options retry: false 5 | 6 | def perform 7 | `pip3 install --upgrade yt-dlp` 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/application_model.rb: -------------------------------------------------------------------------------- 1 | ApplicationModel = Class.new(Sequel::Model) 2 | 3 | class ApplicationModel 4 | end 5 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/devise_user.rb: -------------------------------------------------------------------------------- 1 | class DeviseUser < User 2 | self.raise_on_save_failure = false 3 | plugin :devise 4 | devise(*[ 5 | :database_authenticatable, :token_authenticatable, :recoverable, :rememberable, :trackable, 6 | Podify::Container['settings'].enable_signup ? :registerable : nil, 7 | ].compact) 8 | end 9 | -------------------------------------------------------------------------------- /app/models/download.rb: -------------------------------------------------------------------------------- 1 | class Download < ApplicationModel 2 | include Downloads::FileUploader::Attachment(:file) 3 | 4 | many_to_one :source 5 | 6 | dataset_module do 7 | def by_path(path) 8 | where(path: path.to_s) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/feed.rb: -------------------------------------------------------------------------------- 1 | class Feed < ApplicationModel 2 | many_to_one :user 3 | one_to_many :requests 4 | 5 | dataset_module do 6 | def by_type(type) 7 | where(type: type.to_s) 8 | end 9 | 10 | def by_token(token) 11 | first(token: token) 12 | end 13 | 14 | def with_token!(token) 15 | first!(token: token) 16 | end 17 | 18 | def ordered 19 | order { [position.asc, id.asc] } 20 | end 21 | end 22 | 23 | def before_validation 24 | self.token ||= Devise.friendly_token 25 | super 26 | end 27 | 28 | def requests_dataset 29 | case type 30 | when 'all' 31 | user.requests_dataset 32 | when 'manual' 33 | super 34 | else 35 | raise "Don't have a strategy to get requests for type '#{type}'" 36 | end 37 | end 38 | 39 | def requests 40 | requests_dataset.all 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/models/request.rb: -------------------------------------------------------------------------------- 1 | class Request < ApplicationModel 2 | many_to_one :source 3 | many_to_one :user 4 | many_to_one :feed 5 | end 6 | -------------------------------------------------------------------------------- /app/models/source.rb: -------------------------------------------------------------------------------- 1 | class Source < ApplicationModel 2 | one_to_many :requests 3 | many_to_many :users, join_table: :requests 4 | 5 | one_to_many :downloads 6 | # add_association_dependencies downloads: :destroy 7 | 8 | dataset_module do 9 | def by_file_path(path) 10 | by_url("file://#{path.to_s}") 11 | end 12 | 13 | def by_url(url) 14 | where(url: url) 15 | end 16 | 17 | def without_requests 18 | exclude(id: Request.distinct.select(:source_id)) 19 | end 20 | end 21 | 22 | def download_progress 23 | redis.get(download_progress_redis_key)&.to_f 24 | end 25 | 26 | def download_progress=(progress) 27 | redis.set(download_progress_redis_key, progress, ex: 10) 28 | end 29 | 30 | private 31 | 32 | def download_progress_redis_key 33 | raise "Can only get or set download_progress for persisted sources" unless id 34 | 35 | "source:#{id}:download_progress" 36 | end 37 | 38 | def redis 39 | Podify::Container['redis'] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationModel 2 | one_to_many :requests 3 | many_to_many :sources, join_table: :requests 4 | one_to_many :feeds 5 | 6 | dataset_module do 7 | def by_email(email) 8 | first(email: email) 9 | end 10 | end 11 | 12 | def ensure_authentication_token 13 | if authentication_token.blank? 14 | self.authentication_token = generate_authentication_token 15 | end 16 | end 17 | 18 | def ensure_authentication_token! 19 | ensure_authentication_token 20 | save 21 | end 22 | 23 | private 24 | 25 | def after_create 26 | ensure_all_feed 27 | end 28 | 29 | def ensure_all_feed 30 | feeds_dataset.by_type(:all).first || Feed.create( 31 | user: self, 32 | name: 'All Downloads', 33 | type: 'all', 34 | ) 35 | end 36 | 37 | def before_validation 38 | ensure_authentication_token 39 | end 40 | 41 | def generate_authentication_token 42 | loop do 43 | token = Devise.friendly_token 44 | break token unless User.where(authentication_token: token).first 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/presenters/application_presenter.rb: -------------------------------------------------------------------------------- 1 | class ApplicationPresenter 2 | extend Dry::Initializer 3 | 4 | param :object 5 | 6 | def self.wrap(object) 7 | new(object) 8 | end 9 | 10 | def self.wrap_all(objects) 11 | objects.map { |object| wrap(object) } 12 | end 13 | 14 | def self.forward_or_nil(*methods, to:) 15 | methods.each do |method| 16 | define_method method do 17 | send(to)&.send(method) 18 | end 19 | end 20 | end 21 | 22 | def respond_to_missing?(method, *a, &b) 23 | object.respond_to?(method, *a, &b) 24 | end 25 | 26 | private 27 | 28 | def method_missing(method, *a, &b) 29 | if object.respond_to?(method) 30 | object.send(method, *a, &b) 31 | else 32 | super 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/presenters/download_presenter.rb: -------------------------------------------------------------------------------- 1 | class DownloadPresenter < ApplicationPresenter 2 | forward_or_nil( 3 | :size, 4 | :human_size, 5 | :download_url, 6 | :mime_type, 7 | to: :file 8 | ) 9 | 10 | def format 11 | file.extension 12 | end 13 | 14 | def description 15 | fetcher_information['description'] 16 | end 17 | 18 | def file 19 | @file ||= FilePresenter.wrap(object.file) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/presenters/download_status_presenter.rb: -------------------------------------------------------------------------------- 1 | class DownloadStatusPresenter < ApplicationPresenter 2 | def status 3 | if downloads.any? 4 | 'downloaded' 5 | elsif queued? 6 | 'queued' 7 | elsif retrying? 8 | 'retrying' 9 | elsif downloading? 10 | 'downloading' 11 | elsif dead? 12 | 'dead' 13 | elsif new? 14 | 'pending' 15 | else 16 | 'unknown' 17 | end 18 | end 19 | 20 | def queued? 21 | !!index_of_job_in_queue 22 | end 23 | def retrying? 24 | !!retry_job 25 | end 26 | def dead? 27 | !!index_of_job_in(Sidekiq::DeadSet.new) 28 | end 29 | 30 | def new? 31 | created_at >= 10.seconds.ago 32 | end 33 | 34 | def retry_job 35 | return @retry_job if defined?(@retry_job) 36 | 37 | retries = Sidekiq::RetrySet.new.to_a 38 | index = index_of_job_in(retries) 39 | @retry_job = index && retries[index] 40 | end 41 | 42 | def downloading? 43 | progress || Sidekiq::Workers.new.any? do |_, _, work| 44 | work.dig('payload', 'args', 0) == object.id 45 | end 46 | end 47 | 48 | def index_of_job_in_queue 49 | @index_of_job_in_queue ||= index_of_job_in queue 50 | end 51 | 52 | def place_in_queue 53 | return nil unless index_of_job_in_queue 54 | 55 | place = queue.size - index_of_job_in_queue 56 | "#{place.ordinalize} in queue" 57 | end 58 | 59 | def retry_status 60 | return nil unless retrying? 61 | 62 | out = "Retrying" 63 | 64 | if retry_job.item['retry_count'] 65 | out += " (#{retry_job.item['retry_count']} / 25)" 66 | end 67 | 68 | retry_at = Time.zone.at(retry_job.score) 69 | if retry_at.past? 70 | out += " now" 71 | else 72 | out += " in " + helpers.distance_of_time_in_words(Time.now, retry_at) 73 | end 74 | 75 | # if retry_job.item['error_message'] 76 | # out += " (#{retry_job.item['error_message']})" 77 | # end 78 | 79 | out 80 | end 81 | 82 | def progress 83 | object.download_progress 84 | end 85 | 86 | def for_broadcast 87 | %i[status place_in_queue retry_status].to_h do |attr| 88 | [attr, send(attr)] 89 | end 90 | end 91 | 92 | private 93 | 94 | def index_of_job_in(queue) 95 | queue.find_index do |job| 96 | job.args[0] == object.id 97 | end 98 | end 99 | 100 | def helpers 101 | @helpers ||= Class.new do 102 | extend ActionView::Helpers::DateHelper 103 | end 104 | end 105 | 106 | def queue 107 | @queue ||= Sidekiq::Queue.new('default') 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /app/presenters/feed_presenter.rb: -------------------------------------------------------------------------------- 1 | class FeedPresenter < ApplicationPresenter 2 | def rss_url 3 | Rails.application.routes.url_helpers.feed_url(token, format: :rss) 4 | end 5 | 6 | def requests 7 | RequestPresenter.wrap_all(object.requests) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/presenters/file_presenter.rb: -------------------------------------------------------------------------------- 1 | class FilePresenter < ApplicationPresenter 2 | SIZE_UNITS = [ 3 | { unit: :B, exp: 0 }, 4 | { unit: :KB, exp: 1 }, 5 | { unit: :MB, exp: 2 }, 6 | { unit: :GB, exp: 3 }, 7 | { unit: :TB, exp: 4 }, 8 | ] 9 | 10 | def extension 11 | object.extension 12 | end 13 | 14 | def human_size(units = %i[KB MB GB], decimals: 1) 15 | conv = SIZE_UNITS.reverse.find { |u| units.include?(u[:unit]) && object.size.fdiv(1024 ** u[:exp]) >= 1 } 16 | conv ||= SIZE_UNITS.last 17 | 18 | "#{object.size.fdiv(1024 ** conv[:exp]).round(decimals)} #{conv[:unit]}" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/presenters/request_presenter.rb: -------------------------------------------------------------------------------- 1 | class RequestPresenter < ApplicationPresenter 2 | def source 3 | @source ||= SourcePresenter.wrap(object.source) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/presenters/source_presenter.rb: -------------------------------------------------------------------------------- 1 | class SourcePresenter < ApplicationPresenter 2 | forward_or_nil( 3 | :title, 4 | :author, 5 | :thumbnail_url, 6 | :format, 7 | :description, 8 | to: :last_download 9 | ) 10 | 11 | def download_status 12 | @download_status ||= DownloadStatusPresenter.wrap(object) 13 | end 14 | 15 | def last_download 16 | return @last_download if defined?(@last_download) 17 | 18 | last = downloads_dataset.last 19 | @last_download = last && DownloadPresenter.wrap(last) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/templates/feeds/render.rss.builder: -------------------------------------------------------------------------------- 1 | xml.instruct! :xml, version: "1.0" 2 | 3 | xml.rss( 4 | version: "2.0", 5 | "xmlns:content" => "http://purl.org/rss/1.0/modules/content/", 6 | "xmlns:wfw" => "http://wellformedweb.org/CommentAPI/", 7 | "xmlns:dc" => "http://purl.org/dc/elements/1.1/", 8 | "xmlns:atom" => "http://www.w3.org/2005/Atom", 9 | "xmlns:slash" => "http://purl.org/rss/1.0/modules/slash/", 10 | "xmlns:itunes" => "http://www.itunes.com/dtds/podcast-1.0.dtd", 11 | ) do 12 | 13 | xml.channel do 14 | xml.atom :link, rel: "self", href: request.url, type: "application/rss+xml" 15 | xml.link feed_url 16 | xml.itunes :image, href: asset_pack_url('media/logo/square.png') 17 | xml.lastBuildDate Time.now.rfc822 18 | xml.language "en-US" 19 | xml.generator root_url 20 | 21 | xml.title "Podify: #{feed.name}" 22 | xml.author "Podify" 23 | xml.description "Your downloads on Podify" 24 | 25 | sources.each do |source| 26 | xml.item do 27 | xml.title source.display_title 28 | xml.link source.url 29 | xml.pubDate source.created_at.rfc822 30 | xml.guid "podify-#{feed.id}-#{source.id}", isPermaLink: false 31 | 32 | desc = 33 | xml.description do 34 | xml.cdata! <<~EOF 35 |

From #{link_to source.url, source.url}

36 |

#{source.description}

37 | EOF 38 | end 39 | 40 | xml.itunes :summary, source.description 41 | 42 | xml.itunes :author, source.author 43 | xml.itunes :image, href: source.thumbnail_url 44 | # xml.itunes :duration, item.duration 45 | 46 | if source.last_download 47 | file = source.last_download.file 48 | xml.enclosure url: file.download_url, length: file.size, type: file.mime_type 49 | end 50 | end 51 | end 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /app/templates/layouts/application.html.slim: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title Podify 5 | 6 | meta charset="utf-8" 7 | meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1" 8 | meta name="viewport" content="width=device-width, initial-scale=1.0" 9 | 10 | = stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' 11 | = javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' 12 | 13 | - %w[16 32].each do |size| 14 | = favicon_pack_tag "media/favicon/#{size}.png", rel: 'icon', sizes: "#{size}x#{size}" 15 | 16 | = yield :html_head 17 | 18 | body class=yield(:body_classes) 19 | == yield 20 | 21 | - if false && Rails.env.development? 22 | .container 23 | p= link_to 'Graphiql', graphiql_rails_path 24 | p= link_to 'Sidekiq', sidekiq_web_path 25 | -------------------------------------------------------------------------------- /app/views/application_part.rb: -------------------------------------------------------------------------------- 1 | class ApplicationPart < Dry::View::Part 2 | end 3 | -------------------------------------------------------------------------------- /app/views/application_view.rb: -------------------------------------------------------------------------------- 1 | class ApplicationView < Dry::View 2 | config.paths = Rails.root.join('app/templates') 3 | config.layout = "application" 4 | config.default_context = ApplicationViewContext.new 5 | config.part_namespace = Parts 6 | # config.scope_namespace = Scopes 7 | end 8 | -------------------------------------------------------------------------------- /app/views/application_view_context.rb: -------------------------------------------------------------------------------- 1 | require "dry/view/context" 2 | 3 | class ApplicationViewContext < Dry::View::Context 4 | include Devise::Controllers::Helpers 5 | 6 | def request 7 | _options[:request] 8 | end 9 | 10 | def method_missing(name, *args, &block) 11 | if url_helpers.respond_to?(name) 12 | url_helpers.public_send(name, *args, &block) 13 | elsif helpers.respond_to?(name) 14 | helpers.public_send(name, *args, &block) 15 | else 16 | super 17 | end 18 | end 19 | 20 | private 21 | 22 | def respond_to_missing?(name, include_private = false) 23 | url_helpers.respond_to?(name, include_private) || 24 | helpers.respond_to?(name, include_private) || 25 | super 26 | end 27 | 28 | def url_helpers 29 | @url_helpers ||= Rails.application.routes.url_helpers 30 | end 31 | 32 | def helpers 33 | @helpers ||= ApplicationController.helpers 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/views/layouts/_application.html.slim: -------------------------------------------------------------------------------- 1 | ../../templates/layouts/application.html.slim -------------------------------------------------------------------------------- /app/views/layouts/application.html.slim: -------------------------------------------------------------------------------- 1 | ../../templates/layouts/application.html.slim -------------------------------------------------------------------------------- /app/views/layouts/devise.html.slim: -------------------------------------------------------------------------------- 1 | = content_for :body_classes, 'body-authentication' 2 | 3 | = render layout: 'layouts/application' do 4 | .authentication 5 | .logo 6 | img src= asset_pack_path('media/logo/navbar.png') 7 | .form 8 | == yield 9 | -------------------------------------------------------------------------------- /app/views/parts/source.rb: -------------------------------------------------------------------------------- 1 | module Parts 2 | class Source < ApplicationPart 3 | def display_title 4 | downloads.last&.title || url 5 | end 6 | 7 | def render_form 8 | render :form, source: self 9 | end 10 | 11 | def render_list_entry 12 | render :list_entry, source: self 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/views/feeds/render.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Feeds 3 | class Render < ApplicationView 4 | config.template = "feeds/render" 5 | config.layout = nil 6 | config.default_format = :rss 7 | 8 | expose(:feed) { |feed:| FeedPresenter.wrap(feed) } 9 | expose(:sources) { |feed:| SourcePresenter.wrap_all(feed.requests.map(&:source)) } 10 | expose(:feed_url) { |feed:| Rails.application.routes.url_helpers.root_url + "/feeds/#{feed.id}" } 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/vue_apps/main.html.slim: -------------------------------------------------------------------------------- 1 | - content_for :html_head do 2 | meta name="root-url" content=root_url 3 | meta name="api-url" content=api_url 4 | = action_cable_meta_tag 5 | = javascript_pack_tag 'main' 6 | = stylesheet_pack_tag 'main' 7 | 8 | = content_for :body_classes, 'body-app' 9 | 10 | #main 11 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | var validEnv = ['development', 'test', 'production'] 3 | var currentEnv = api.env() 4 | var isDevelopmentEnv = api.env('development') 5 | var isProductionEnv = api.env('production') 6 | var isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ] 38 | ].filter(Boolean), 39 | plugins: [ 40 | 'babel-plugin-macros', 41 | '@babel/plugin-syntax-dynamic-import', 42 | isTestEnv && 'babel-plugin-dynamic-import-node', 43 | '@babel/plugin-transform-destructuring', 44 | [ 45 | '@babel/plugin-proposal-class-properties', 46 | { 47 | loose: true 48 | } 49 | ], 50 | [ 51 | '@babel/plugin-proposal-object-rest-spread', 52 | { 53 | useBuiltIns: true 54 | } 55 | ], 56 | [ 57 | '@babel/plugin-transform-runtime', 58 | { 59 | helpers: false, 60 | regenerator: true, 61 | corejs: false 62 | } 63 | ], 64 | [ 65 | '@babel/plugin-transform-regenerator', 66 | { 67 | async: false 68 | } 69 | ] 70 | ].filter(Boolean) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= 65 | env_var_version || cli_arg_version || 66 | lockfile_version 67 | end 68 | 69 | def bundler_requirement 70 | return "#{Gem::Requirement.default}.a" unless bundler_version 71 | 72 | bundler_gem_version = Gem::Version.new(bundler_version) 73 | 74 | requirement = bundler_gem_version.approximate_recommendation 75 | 76 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0") 77 | 78 | requirement += ".a" if bundler_gem_version.prerelease? 79 | 80 | requirement 81 | end 82 | 83 | def load_bundler! 84 | ENV["BUNDLE_GEMFILE"] ||= gemfile 85 | 86 | activate_bundler 87 | end 88 | 89 | def activate_bundler 90 | gem_error = activation_error_handling do 91 | gem "bundler", bundler_requirement 92 | end 93 | return if gem_error.nil? 94 | require_error = activation_error_handling do 95 | require "bundler/version" 96 | end 97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 99 | exit 42 100 | end 101 | 102 | def activation_error_handling 103 | yield 104 | nil 105 | rescue StandardError, LoadError => e 106 | e 107 | end 108 | end 109 | 110 | m.load_bundler! 111 | 112 | if m.invoked_as_script? 113 | load Gem.bin_path("bundler", "bundle") 114 | end 115 | -------------------------------------------------------------------------------- /bin/podify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative '../config/boot' 4 | require_relative '../config/environment' 5 | #require 'cli/commands' 6 | 7 | Dry::CLI.new(CLI::Commands).call 8 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | puts "\n== Removing old logs and tempfiles ==" 21 | system! "bin/rails log:clear tmp:clear" 22 | 23 | puts "\n== Restarting application server ==" 24 | system! "bin/rails restart" 25 | end 26 | -------------------------------------------------------------------------------- /bin/start-server: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | bundle exec rails server -b 0.0.0.0 5 | -------------------------------------------------------------------------------- /bin/start-worker: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | bundle exec sequel -m db/migrate "$DATABASE_URL" 5 | pip3 install --upgrade yt-dlp 6 | 7 | bundle exec sidekiq -c 3 8 | -------------------------------------------------------------------------------- /bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::WebpackRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::DevServerRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | begin 5 | exec "yarnpkg", *ARGV 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | # require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | require "action_cable/engine" 15 | # require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module Podify 22 | class Application < Rails::Application 23 | config.load_defaults 7.0 24 | 25 | # Don't generate system test files. 26 | config.generators.system_tests = nil 27 | 28 | config.autoload_paths << Rails.root.join('app/views') 29 | 30 | # Remove heinous monkey patch 31 | Dry::View::Part.undef_method :to_param 32 | 33 | # config.sequel.skip_connect = true 34 | Sequel.extension(:pg_json_ops) 35 | 36 | config.sequel.after_connect = proc do |conn| 37 | Sequel::Model.plugin :timestamps, update_on_create: true 38 | Sequel::Model.plugin :association_dependencies 39 | Sequel::Model.plugin :tactical_eager_loading 40 | 41 | Sequel::Model.db.extension :pg_array 42 | Sequel::Model.db.extension :pg_json 43 | Sequel::Model.db.extension :pg_enum 44 | end 45 | 46 | config.action_cable.mount_path = '/api/cable' 47 | 48 | if ENV['URL_HOST'] 49 | Rails.application.routes.default_url_options[:host] = ENV['URL_HOST'] 50 | config.action_controller.asset_host = ENV['URL_HOST'] 51 | elsif ENV['HOST'] 52 | host = "http#{'s' if ENV['SSL']}://#{ENV.fetch('HOST')}:#{ENV.fetch('PORT')}" 53 | Rails.application.routes.default_url_options[:host] = host 54 | config.action_controller.asset_host = host 55 | config.hosts << ENV.fetch('HOST') 56 | end 57 | 58 | if ENV['EXTRA_HOSTS'] 59 | config.hosts += ENV['EXTRA_HOSTS'].split(',') 60 | end 61 | 62 | if ENV['FIX_DOCKER_LOGS'] 63 | log_out = IO.new(IO.sysopen("/proc/1/fd/1", "w"),"w") 64 | log_out.sync = true 65 | config.logger = Logger.new(log_out) 66 | end 67 | 68 | Raven.configure do |config| 69 | config.dsn = ENV['SENTRY_DSN'] 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: "<%= ENV['REDIS_URL'] %>" 4 | channel_prefix: podify_development 5 | 6 | production: 7 | adapter: redis 8 | url: "<%= ENV['REDIS_URL'] %>" 9 | channel_prefix: podify_production 10 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | Ns+vgheSqeBzCkJEn3JaLcANplCbS4BKtTcwehf9lA650jBJEPjYiDyz1BlBbnWO6+sqp7uhfFjrqU/7d4IOGOyH73X2CbbSGY93ioujyCMEy6yfkQi1//H3olRjf3OuoFgeyircvP3A6RXd5ac9nm6S2AONqgpvvG5XXSmxD9tpmYNtL8L5Pf06XelPUcDxSTD+3ceK0MxMf7EZHlNG3L2mjEYAP/duxKt74R3vj69/NKcCA5ynbKrJlnJe2w4jPTlwV/yWNPscprB17qo4SnmXyjew57ltSGXAFh+zkVaeFqaQW2T628NlWqROpigycKBqKUKCH0Ipm3rfI/NxINyvEdQlfIYvKsYcTLb0IWeBNlFI0WsDrU6aIPBptQMAsniPTtkakCHrAV1sW44K4y7Pn6fagBt8npB8--EpgPmI4ESLbWQ887--34nkmlk31W2bpPQg6KHJOQ== -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | _default: &default 2 | adapter: postgres 3 | encoding: unicode 4 | pool: 5 5 | url: "<%= ENV['DATABASE_URL'] %>" 6 | 7 | development: 8 | <<: *default 9 | 10 | test: 11 | <<: *default 12 | 13 | production: 14 | <<: *default 15 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Don't care if the mailer can't send. 37 | config.action_mailer.raise_delivery_errors = false 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Print deprecation notices to the Rails logger. 42 | config.active_support.deprecation = :log 43 | 44 | # Raise exceptions for disallowed deprecations. 45 | config.active_support.disallowed_deprecation = :raise 46 | 47 | # Tell Active Support which deprecation messages to disallow. 48 | config.active_support.disallowed_deprecation_warnings = [] 49 | 50 | # Suppress logger output for asset requests. 51 | config.assets.quiet = true 52 | 53 | # Raises error for missing translations. 54 | # config.i18n.raise_on_missing_translations = true 55 | 56 | # Annotate rendered view with file names. 57 | # config.action_view.annotate_rendered_view_with_filenames = true 58 | 59 | config.action_mailer.delivery_method = :letter_opener 60 | config.action_mailer.perform_deliveries = true 61 | 62 | # Uncomment if you wish to allow Action Cable access from any origin. 63 | # config.action_cable.disable_request_forgery_protection = true 64 | end 65 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Tell Action Mailer not to deliver emails to the real world. 39 | # The :test delivery method accumulates sent emails in the 40 | # ActionMailer::Base.deliveries array. 41 | config.action_mailer.delivery_method = :test 42 | 43 | # Print deprecation notices to the stderr. 44 | config.active_support.deprecation = :stderr 45 | 46 | # Raise exceptions for disallowed deprecations. 47 | config.active_support.disallowed_deprecation = :raise 48 | 49 | # Tell Active Support which deprecation messages to disallow. 50 | config.active_support.disallowed_deprecation_warnings = [] 51 | 52 | # Raises error for missing translations. 53 | # config.i18n.raise_on_missing_translations = true 54 | 55 | # Annotate rendered view with file names. 56 | # config.action_view.annotate_rendered_view_with_filenames = true 57 | end 58 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /config/initializers/dry-view-reloading-patch.rb: -------------------------------------------------------------------------------- 1 | # TODO: only in development 2 | 3 | require 'dry/view' 4 | require 'dry/view/renderer' 5 | require 'dry/view/path' 6 | require 'dry/view/scope_builder' 7 | require 'dry/view/part_builder' 8 | require 'dry/view/tilt' 9 | 10 | module Dry 11 | class View 12 | def self.reset_cache! 13 | [self, Renderer, Path, ScopeBuilder, PartBuilder, Tilt].each do |klass| 14 | klass.instance_variable_set(:@__cache__, ::Concurrent::Map.new) 15 | end 16 | nil 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | 18 | ActiveSupport::Inflector.inflections(:en) do |inflect| 19 | inflect.acronym 'GraphQL' 20 | inflect.acronym 'API' 21 | end 22 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /config/initializers/sequel.rb: -------------------------------------------------------------------------------- 1 | DB = Sequel::Model.db 2 | -------------------------------------------------------------------------------- /config/initializers/shrine.rb: -------------------------------------------------------------------------------- 1 | require 'shrine/storage/file_system' 2 | 3 | Shrine.storages = { 4 | store: Shrine::Storage::FileSystem.new(ENV.fetch('STORAGE_DIR', 'tmp/storage')), 5 | cache: Shrine::Storage::FileSystem.new(ENV.fetch('CACHE_STORAGE_DIR', 'tmp/storage/cache')), 6 | } 7 | 8 | Shrine.plugin :sequel 9 | Shrine.plugin :upload_options, cache: { move: true }, store: { move: true } 10 | Shrine.plugin :cached_attachment_data 11 | Shrine.plugin :restore_cached_data 12 | -------------------------------------------------------------------------------- /config/initializers/system.rb: -------------------------------------------------------------------------------- 1 | Dry::Rails.container do 2 | # cherry-pick features 3 | config.auto_inject_constant = 'Import' 4 | config.features = %i[application_contract safe_params controller_helpers] 5 | 6 | register('events', memoize: true) do 7 | require 'events/publisher' 8 | Events::Publisher.new 9 | end 10 | 11 | register('logger', memoize: true) do 12 | if Rails.env.development? || Rails.env.test? 13 | Logger.new( 14 | STDOUT, 15 | formatter: proc { |severity, datetime, progname, msg| 16 | date_format = datetime.strftime("%Y-%m-%d %H:%M:%S") 17 | out = "#{date_format.blue}" 18 | out += " #{severity.red}" 19 | out += " (#{progname})" if progname 20 | out += " #{msg}\n" 21 | out 22 | } 23 | ) 24 | else 25 | Logger.new(STDOUT) 26 | end 27 | end 28 | 29 | register('downloader.result') do 30 | require 'downloader/result' 31 | Downloader::Result 32 | end 33 | 34 | register('action_cable.server') do 35 | ActionCable.server 36 | end 37 | 38 | register('redis', memoize: true) do 39 | Redis.new 40 | end 41 | 42 | auto_register!('lib') 43 | auto_register!('app/views') 44 | auto_register!('app/jobs') do |config| 45 | config.memoize = true 46 | config.instance { |component| component.loader.constant } 47 | end 48 | 49 | setting :env, ENV 50 | boot(:settings, from: :system) do 51 | settings do 52 | key :storage_dir, Types::Coercible::Pathname.constrained(filled: true).default(Rails.root.join('tmp', 'storage').freeze) 53 | 54 | key :initial_user_email, Types::String.optional.default(nil) 55 | key :initial_user_password, Types::String.optional.default(nil) 56 | 57 | key :enable_signup, Types::Coercible::YesNoBoolean.optional.default(true) 58 | end 59 | end 60 | end 61 | 62 | Dry::System.register_provider( 63 | :podify, 64 | boot_path: Rails.root.join('config', 'system').realpath 65 | ) 66 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/youtube_dl.rb: -------------------------------------------------------------------------------- 1 | YoutubeDL::Command.config.executable = 'yt-dlp' 2 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | time: 35 | formats: 36 | rss: "%a, %d %b %Y %H:%M:%S %Z %:z" 37 | -------------------------------------------------------------------------------- /config/locales/formats.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | time: 3 | formats: 4 | time: "%l:%M%P UTC" 5 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch("PORT") { 3000 } 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch("RAILS_ENV") { "development" } 18 | 19 | # Specifies the `pidfile` that Puma will use. 20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 21 | 22 | # Specifies the number of `workers` to boot in clustered mode. 23 | # Workers are forked web server processes. If using threads and workers together 24 | # the concurrency of the application would be max `threads` * `workers`. 25 | # Workers do not work on JRuby or Windows (both of which do not support 26 | # processes). 27 | # 28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 29 | 30 | # Use the `preload_app!` method when specifying a `workers` number. 31 | # This directive tells Puma to first boot the application and load code 32 | # before forking the application. This takes advantage of Copy On Write 33 | # process behavior so workers use less memory. 34 | # 35 | # preload_app! 36 | 37 | # Allow puma to be restarted by `rails restart` command. 38 | plugin :tmp_restart 39 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/web' 2 | 3 | Rails.application.routes.draw do 4 | if Rails.env.development? 5 | mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/api" 6 | end 7 | post "/api", to: "graphql#execute" 8 | 9 | root to: 'vue_apps#main' 10 | devise_for :user, class_name: 'DeviseUser' 11 | 12 | get 'rss/:token', to: 'feeds#show', as: :feed, constraints: { format: :rss } 13 | 14 | mount Downloads::FileUploader.download_endpoint => '/download' 15 | mount Sidekiq::Web => '/sidekiq' 16 | 17 | get '*path', to: 'vue_apps#main' 18 | end 19 | -------------------------------------------------------------------------------- /config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | :schedule: 2 | garbage_collect: 3 | cron: '* * * * *' 4 | class: GarbageCollectionJob 5 | 6 | upgrade_youtube_dl: 7 | cron: '6 * * * *' 8 | class: UpgradeYoutubeDlJob 9 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /config/system/event_logger.rb: -------------------------------------------------------------------------------- 1 | Dry::Rails.container do 2 | boot(:event_logger) do 3 | start do 4 | Podify::Container['events.logger'].subscribe 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/system/initial_user.rb: -------------------------------------------------------------------------------- 1 | Dry::Rails.container do 2 | boot(:initial_user) do |container| 3 | start do 4 | Podify::Container['users.create_initial'].call.or do |error| 5 | raise error.to_s 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | const { VueLoaderPlugin } = require('vue-loader') 3 | const vue = require('./loaders/vue') 4 | 5 | environment.plugins.prepend('VueLoaderPlugin', new VueLoaderPlugin()) 6 | environment.loaders.prepend('vue', vue) 7 | module.exports = environment 8 | -------------------------------------------------------------------------------- /config/webpack/loaders/vue.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | test: /\.vue(\.erb)?$/, 3 | use: [{ 4 | loader: 'vue-loader' 5 | }] 6 | } 7 | -------------------------------------------------------------------------------- /config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpacker.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | source_path: app/javascript 5 | source_entry_path: packs 6 | public_root_path: public 7 | public_output_path: packs 8 | cache_path: tmp/cache/webpacker 9 | check_yarn_integrity: false 10 | webpack_compile_output: true 11 | 12 | # Additional paths webpack should lookup modules 13 | # ['app/assets', 'engine/foo/app/assets'] 14 | resolved_paths: [] 15 | 16 | # Reload manifest.json on all requests so we reload latest compiled packs 17 | cache_manifest: false 18 | 19 | # Extract and emit a css file 20 | extract_css: false 21 | 22 | static_assets_extensions: 23 | - .jpg 24 | - .jpeg 25 | - .png 26 | - .gif 27 | - .tiff 28 | - .ico 29 | - .svg 30 | - .eot 31 | - .otf 32 | - .ttf 33 | - .woff 34 | - .woff2 35 | 36 | extensions: 37 | - .vue 38 | - .mjs 39 | - .js 40 | - .sass 41 | - .scss 42 | - .css 43 | - .module.sass 44 | - .module.scss 45 | - .module.css 46 | - .png 47 | - .svg 48 | - .gif 49 | - .jpeg 50 | - .jpg 51 | 52 | development: 53 | <<: *default 54 | compile: true 55 | 56 | # Verifies that correct packages and versions are installed by inspecting package.json, yarn.lock, and node_modules 57 | check_yarn_integrity: true 58 | 59 | # Reference: https://webpack.js.org/configuration/dev-server/ 60 | dev_server: 61 | https: false 62 | host: localhost 63 | port: 3035 64 | public: localhost:3035 65 | hmr: false 66 | # Inline should be set to true if using HMR 67 | inline: true 68 | overlay: true 69 | compress: true 70 | disable_host_check: true 71 | use_local_ip: false 72 | quiet: false 73 | pretty: false 74 | headers: 75 | 'Access-Control-Allow-Origin': '*' 76 | watch_options: 77 | ignored: '**/node_modules/**' 78 | 79 | 80 | test: 81 | <<: *default 82 | compile: true 83 | 84 | # Compile test packs to a separate directory 85 | public_output_path: packs-test 86 | 87 | production: 88 | <<: *default 89 | 90 | # Production depends on precompilation of packs prior to booting for performance. 91 | compile: false 92 | 93 | # Extract and emit a css file 94 | extract_css: true 95 | 96 | # Cache manifest.json for performance 97 | cache_manifest: true 98 | -------------------------------------------------------------------------------- /db/migrate/20200412191625_create_first_tables.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | 4 | create_table :sources do 5 | primary_key :id 6 | 7 | String :url, null: false, unique: true 8 | String :title 9 | DateTime :downloaded_at 10 | 11 | DateTime :created_at, null: false 12 | DateTime :updated_at, null: false 13 | end 14 | 15 | create_table :downloads do 16 | primary_key :id 17 | 18 | foreign_key :source_id, :sources, null: false 19 | String :path, null: true, unique: true 20 | String :fetcher, null: false 21 | String :author 22 | String :title 23 | String :thumbnail_url 24 | column :fetcher_information, :jsonb, null: false 25 | 26 | DateTime :created_at, null: false 27 | DateTime :updated_at, null: false 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20200423145735_remove_title_from_sources.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | up do 3 | alter_table :sources do 4 | drop_column :title 5 | end 6 | end 7 | 8 | down do 9 | alter_table :sources do 10 | add_column :title, String 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20200425113118_create_users.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | 4 | create_table :users do 5 | primary_key :id 6 | 7 | String :email, :null => false, :default => "" 8 | String :encrypted_password, :null => false, :default => "" 9 | 10 | ## Recoverable 11 | String :reset_password_token 12 | String :reset_password_sent_at 13 | 14 | ## Rememberable 15 | DateTime :remember_created_at 16 | 17 | ## Trackable 18 | Integer :sign_in_count, :default => 0, :null => false 19 | DateTime :current_sign_in_at 20 | DateTime :last_sign_in_at 21 | String :current_sign_in_ip 22 | String :last_sign_in_ip 23 | 24 | ## Confirmable 25 | String :confirmation_token 26 | DateTime :confirmed_at 27 | DateTime :confirmation_sent_at 28 | String :unconfirmed_email # Only if using reconfirmable 29 | 30 | ## Lockable 31 | # Integer :failed_attempts, :default => 0, :null => false # Only if lock strategy is :failed_attempts 32 | # String :unlock_token # Only if unlock strategy is :email or :both 33 | # DateTime :locked_at 34 | end 35 | 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /db/migrate/20200501124610_create_requests.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | 4 | create_table :requests do 5 | primary_key :id 6 | foreign_key :user_id, :users, null: false 7 | foreign_key :source_id, :sources, null: false 8 | 9 | DateTime :created_at, null: false 10 | DateTime :updated_at, null: false 11 | 12 | index [:user_id, :source_id], unique: true 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20200501175137_add_authentication_token_to_users.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table :users do 4 | add_column :authentication_token, :text, unique: true 5 | add_column :authentication_token_created_at, DateTime 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20200508224831_create_feeds.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | 4 | create_table :feeds do 5 | primary_key :id 6 | 7 | foreign_key :user_id, :users, null: false 8 | String :type, null: false, default: 'all' 9 | String :name, null: false 10 | String :token, null: false, unique: true 11 | 12 | DateTime :created_at, null: false 13 | DateTime :updated_at, null: false 14 | end 15 | 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20200519161740_add_file_data_to_downloads.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | add_column :downloads, :file_data, :jsonb 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200522123751_remove_path_from_downloads.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | up do 3 | alter_table :downloads do 4 | drop_column :path 5 | end 6 | end 7 | 8 | down do 9 | alter_table :downloads do 10 | add_column :path, String, null: true, unique: true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20200522131956_add_feed_id_to_requests.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table :requests do 4 | add_foreign_key :feed_id, :feeds 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200523151156_add_position_to_feeds.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table :feeds do 4 | add_column :position, Integer, null: false, default: 9999999 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/demo.gif -------------------------------------------------------------------------------- /dev-env/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | db: 5 | image: postgres:12.3 6 | environment: 7 | POSTGRES_USER: postgres 8 | POSTGRES_PASSWORD: "" 9 | POSTGRES_HOST_AUTH_METHOD: trust 10 | volumes: 11 | - ./postgres/init:/docker-entrypoint-initdb.d 12 | ports: 13 | - "5432:5432" 14 | 15 | redis: 16 | image: redis:6 17 | ports: 18 | - "6379:6379" 19 | 20 | # memcached: 21 | # image: memcached 22 | # network_mode: host 23 | # ports: 24 | # - "11211:11211" 25 | -------------------------------------------------------------------------------- /dev-env/postgres/init/main.sh: -------------------------------------------------------------------------------- 1 | psql -U postgres -c "CREATE USER podify;" 2 | 3 | createdb -U postgres -O podify podify_development 4 | createdb -U postgres -O podify podify_test 5 | -------------------------------------------------------------------------------- /dev-env/tmuxp.yml: -------------------------------------------------------------------------------- 1 | session_name: podify 2 | windows: 3 | - window_name: all-processes 4 | layout: tiled 5 | panes: 6 | - shell_command: 7 | - tmux set -gw pane-border-status top 8 | 9 | - printf '\033]2;%s\033\\' 'Docker services' 10 | - cd dev-env 11 | - docker compose up 12 | 13 | - shell_command: 14 | - tmux set -gw pane-border-status top 15 | - printf '\033]2;%s\033\\' 'Server' 16 | - bundle exec rails s 17 | 18 | - shell_command: 19 | - tmux set -gw pane-border-status top 20 | - printf '\033]2;%s\033\\' 'Worker' 21 | - bundle exec sidekiq 22 | 23 | - shell_command: 24 | - tmux set -gw pane-border-status top 25 | - printf '\033]2;%s\033\\' 'webpack-dev-server' 26 | - bin/webpack-dev-server 27 | -------------------------------------------------------------------------------- /dev-env/up: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 4 | 5 | pushd . > /dev/null 6 | cd "$SCRIPT_DIR/.." 7 | 8 | tmuxp load dev-env/tmuxp.yml 9 | 10 | popd > /dev/null 11 | -------------------------------------------------------------------------------- /docker/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose -f docker/docker-compose.build.yml build 4 | -------------------------------------------------------------------------------- /docker/docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | podify: 5 | image: maxhollmann/podify:dev 6 | build: 7 | context: .. 8 | -------------------------------------------------------------------------------- /docker/docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | x-app-defaults: &app-defaults 4 | restart: always 5 | environment: &app-env 6 | URL_HOST: https://podify.yourdomain.com 7 | DATABASE_URL: postgres://podify:verysecurepassword@db/podify 8 | REDIS_URL: redis://redis 9 | SECRET_KEY_BASE: a57d57661ef5df58b46fab6f04304e89108f22f89b31d2242b31891102da87d519a1f3c6459c1d2716b3b8c5438ef43e06ed4c29c8fb059eb650dc2ec0062d57 10 | STORAGE_DIR: /storage 11 | INITIAL_USER_EMAIL: you@example.com 12 | INITIAL_USER_PASSWORD: yourpassword 13 | ENABLE_SIGNUP: "no" 14 | 15 | volumes: 16 | - storage:/storage 17 | 18 | depends_on: 19 | - db 20 | - redis 21 | 22 | services: 23 | web: 24 | <<: *app-defaults 25 | image: maxhollmann/podify:latest 26 | command: start-server 27 | ports: 28 | - 3000:3000 29 | environment: 30 | <<: *app-env 31 | 32 | worker: 33 | <<: *app-defaults 34 | image: maxhollmann/podify:latest 35 | command: start-worker 36 | environment: 37 | <<: *app-env 38 | 39 | db: 40 | image: postgres:12.3 41 | restart: always 42 | environment: 43 | POSTGRES_USER: podify 44 | POSTGRES_PASSWORD: verysecurepassword 45 | PGDATA: /var/lib/postgresql/data/pgdata 46 | volumes: 47 | - pgdata:/var/lib/postgresql/data/pgdata 48 | 49 | redis: 50 | image: redis:6 51 | restart: always 52 | 53 | volumes: 54 | pgdata: 55 | storage: 56 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Remove a potentially pre-existing server.pid for Rails. 5 | rm -f /app/tmp/pids/server.pid 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /docker/push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ "$1" = "-h" ]]; then 4 | echo "Usage: docker-push [tag]" 5 | echo 6 | echo " tag defaults to 'dev'" 7 | exit 1 8 | fi 9 | 10 | tag=${1:-dev} 11 | 12 | image=maxhollmann/podify 13 | 14 | echo "Tagging $image:dev as $image:$tag" 15 | docker tag $image:dev $image:$tag 16 | 17 | echo "Pushing $image:$tag" 18 | docker push $image:$tag 19 | -------------------------------------------------------------------------------- /lib/application_contract.rb: -------------------------------------------------------------------------------- 1 | require 'dry/validation' 2 | require 'dry/monads' 3 | 4 | Dry::Validation.load_extensions(:monads) 5 | 6 | class ApplicationContract < Dry::Validation::Contract 7 | config.messages.top_namespace = :contracts 8 | config.messages.backend = :i18n 9 | config.messages.load_paths += Dir[Rails.root.join("config/locales/*.yml")] 10 | end 11 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/lib/assets/.keep -------------------------------------------------------------------------------- /lib/cli/commands.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cli/commands/downloads/delete' 3 | require 'cli/commands/sources/list' 4 | require 'cli/commands/sources/add' 5 | require 'cli/commands/sources/import' 6 | require 'cli/commands/sources/fetch' 7 | require 'cli/commands/sources/delete' 8 | require 'cli/commands/sources/request' 9 | 10 | module CLI 11 | module Commands 12 | extend Dry::CLI::Registry 13 | 14 | register "downloads", aliases: ["d"] do |prefix| 15 | prefix.register "delete", Downloads::Delete, aliases: ['d'] 16 | end 17 | 18 | register "sources", aliases: ["s"] do |prefix| 19 | prefix.register "list", Sources::List, aliases: ['l'] 20 | prefix.register "add", Sources::Add, aliases: ['a'] 21 | prefix.register "import", Sources::Import, aliases: ['i'] 22 | prefix.register "fetch", Sources::Fetch, aliases: ['f'] 23 | prefix.register "delete", Sources::Delete, aliases: ['d'] 24 | prefix.register "request", Sources::Request, aliases: ['r'] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cli/commands/downloads/delete.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | 3 | module CLI 4 | module Commands 5 | module Downloads 6 | class Delete < Dry::CLI::Command 7 | desc "Delete downloads" 8 | 9 | include Podify::Import[ 10 | 'events', 11 | 'downloads.destroy' 12 | ] 13 | 14 | option :all, type: :boolean, default: false, desc: 'Delete all downloads' 15 | 16 | def call(all:, args: []) 17 | subscribe_to_events 18 | 19 | if all 20 | Download.each do |download| 21 | destroy.call(download) 22 | end 23 | else 24 | puts "Don't know which downloads to delete" 25 | end 26 | end 27 | 28 | def subscribe_to_events 29 | events.subscribe('downloads.destroyed') do |event| 30 | puts "Deleted #{event[:download].path}" 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/add.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | 3 | module CLI 4 | module Commands 5 | module Sources 6 | class Add < Dry::CLI::Command 7 | desc "Add a source" 8 | 9 | include Dry::Monads[:result] 10 | 11 | include Podify::Import[ 12 | 'sources.create' 13 | ] 14 | 15 | option :url, type: :string, required: true, desc: 'URL of the media source' 16 | 17 | def call(url:) 18 | case create.call(url: url) 19 | in Success(source) 20 | puts "Source created, id: #{source.id}" 21 | in Failure(failure) 22 | puts "Failed:" 23 | ap failure 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/delete.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cli/commands/sources/selection' 3 | 4 | module CLI 5 | module Commands 6 | module Sources 7 | class Delete < Dry::CLI::Command 8 | desc "Delete sources" 9 | 10 | include Podify::Import[ 11 | 'events', 12 | 'sources.destroy' 13 | ] 14 | 15 | include Selection 16 | 17 | def call(**args) 18 | subscribe_to_events 19 | 20 | selected_sources(args).each do |source| 21 | destroy.call(source) 22 | end 23 | end 24 | 25 | def subscribe_to_events 26 | events.subscribe('sources.destroyed') do |event| 27 | puts "Deleted source #{event[:source].url}" 28 | end 29 | events.subscribe('downloads.destroyed') do |event| 30 | puts "Deleted download #{event[:download].path}" 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/fetch.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cli/commands/sources/selection' 3 | 4 | module CLI 5 | module Commands 6 | module Sources 7 | class Fetch < Dry::CLI::Command 8 | include Dry::Monads[:result] 9 | 10 | include Podify::Import[ 11 | 'events', 12 | 'downloader.fetch_source' 13 | ] 14 | 15 | include Selection 16 | 17 | desc "Fetch sources" 18 | 19 | def call(**args) 20 | selected_sources(args).each do |source| 21 | puts "Downloading source #{source.id}: '#{source.url}'" 22 | case fetch_source.call(source) 23 | in Success(download) 24 | puts "Downloaded to '#{download.path}'" 25 | in Failure(error, {output:}) 26 | puts "Failed with #{error}. Output:\n\n#{output}" 27 | in failure 28 | puts "Unknown failure: #{failure}" 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/import.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | 3 | module CLI 4 | module Commands 5 | module Sources 6 | class Import < Dry::CLI::Command 7 | desc "Import existing files or directories" 8 | 9 | include Podify::Import[ 10 | 'events', 11 | 'sources.import_file', 12 | 'sources.import_directory' 13 | ] 14 | 15 | def call(args: []) 16 | subscribe_to_events 17 | 18 | args.each do |arg| 19 | path = Pathname(arg) 20 | if path.file? 21 | import = import_file 22 | elsif path.directory? 23 | import = import_directory 24 | else 25 | puts "#{path} does not exist" 26 | next 27 | end 28 | 29 | puts "Importing #{path}..." 30 | import.call(path) 31 | end 32 | 33 | puts 34 | puts "Imported #{@imported} files" 35 | end 36 | 37 | def subscribe_to_events 38 | @imported = 0 39 | 40 | events.subscribe('sources.created') do |event| 41 | puts "Imported \"#{event[:source].url}\" from #{event[:source].url}" 42 | @imported += 1 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/list.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cli/commands/sources/selection' 3 | 4 | module CLI 5 | module Commands 6 | module Sources 7 | class List < Dry::CLI::Command 8 | desc "List sources" 9 | 10 | include Selection 11 | 12 | def call(**args) 13 | selected_sources(args).each do |source| 14 | print "#{source.id}: #{source.url}" 15 | download = source.downloads.last 16 | print ": '#{download.path}'" if download 17 | puts 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/request.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | require 'cli/commands/sources/selection' 3 | require 'cli/commands/user' 4 | 5 | module CLI 6 | module Commands 7 | module Sources 8 | class Request < Dry::CLI::Command 9 | desc "Create requests for sources from a user" 10 | 11 | include Dry::Monads[:result] 12 | 13 | include Podify::Import[ 14 | create_request: 'requests.create', 15 | ] 16 | 17 | include Selection 18 | include User 19 | 20 | def call(**args) 21 | user = selected_user(args) 22 | selected_sources(args).each do |source| 23 | case create_request.call(user_id: user.id, source_id: source.id) 24 | in Success(request) 25 | puts "#{request.source.url} requested" 26 | in Failure('requests.create.exists') 27 | puts "Already requested #{source.url}" 28 | in Failure(failure) 29 | puts "Failed requesting #{source.url}: #{failure}" 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cli/commands/sources/selection.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | 3 | module CLI 4 | module Commands 5 | module Sources 6 | module Selection 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | option :all, type: :boolean, default: false, desc: 'All sources' 11 | option :id, type: :integer, default: nil, desc: '' 12 | end 13 | 14 | def selected_sources(args) 15 | if !args[:all] && args[:id].nil? 16 | puts 'Don\'t know which sources to select.' 17 | return [] 18 | end 19 | 20 | sources = Source.dataset 21 | sources = sources.where(id: args[:id]) if args[:id] 22 | 23 | sources.to_a.tap do |sources| 24 | if sources.none? 25 | puts 'No sources found' 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cli/commands/user.rb: -------------------------------------------------------------------------------- 1 | require 'dry/cli' 2 | 3 | module CLI 4 | module Commands 5 | module User 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | option :email, type: :string, desc: 'Email of the user' 10 | end 11 | 12 | def selected_user(args) 13 | email = args.fetch(:email) 14 | 15 | ::User.by_email(email).tap do |user| 16 | unless user 17 | puts "Can't find user with email #{email}" 18 | exit 1 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/downloader/fetch_source.rb: -------------------------------------------------------------------------------- 1 | module Downloader 2 | class FetchSource 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'downloader.fetcher_factory', 7 | 'events', 8 | create_download: 'downloads.create', 9 | ] 10 | 11 | include Dry::Effects.Resolve(progress_callback: 'downloader.fetch_source.progress_callback') 12 | 13 | def call(source) 14 | progress_callback.start_download 15 | 16 | fetcher = yield fetcher_factory.call(source) 17 | result = yield fetcher.call(source) 18 | events.publish('downloader.fetch_source.fetched', result: result) 19 | download = yield create_download_from_result(result: result, source: source) 20 | 21 | progress_callback.complete 22 | 23 | Success(download) 24 | end 25 | 26 | def create_download_from_result(result:, source:) 27 | download = yield create_download.call(**result.to_h, path: result[:path].to_s, source_id: source.id) 28 | Success(download) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/downloader/fetcher_factory.rb: -------------------------------------------------------------------------------- 1 | module Downloader 2 | class FetcherFactory 3 | include Dry::Monads[:result] 4 | 5 | include Podify::Import[ 6 | 'downloader.fetchers.file', 7 | 'downloader.fetchers.youtube_dl', 8 | ] 9 | 10 | def call(source) 11 | case source.url 12 | when %r{\Afile://}i then Success(file) 13 | when %r{\Ahttps?://}i then Success(youtube_dl) 14 | else Failure('downloader.fetcher_factory.no_fetcher_for_scheme') 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/downloader/fetchers/file.rb: -------------------------------------------------------------------------------- 1 | module Downloader 2 | module Fetchers 3 | class File 4 | include Dry::Monads[:result, :do] 5 | 6 | include Podify::Import['downloader.result'] 7 | 8 | def call(source) 9 | path = yield get_path(source.url) 10 | Success(create_result(path)) 11 | end 12 | 13 | def get_path(url) 14 | if url =~ %r{\Afile://}i 15 | Success(Pathname(url.sub(%r{\Afile://}i, ''))) 16 | else 17 | Failure('downloader.fetchers.file.not_a_file_url') 18 | end 19 | end 20 | 21 | def create_result(path) 22 | result.new( 23 | fetcher: 'file', 24 | path: path, 25 | title: path.basename.sub_ext('').to_s, 26 | fetcher_information: {}, 27 | ) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/downloader/fetchers/youtube_dl.rb: -------------------------------------------------------------------------------- 1 | module Downloader 2 | module Fetchers 3 | class YoutubeDl 4 | include Dry::Monads[:result, :do] 5 | 6 | include Podify::Import[ 7 | 'settings', 8 | 'downloader.result', 9 | ] 10 | 11 | include Dry::Effects.Resolve(progress_callback: 'downloader.fetch_source.progress_callback') 12 | 13 | def call(source) 14 | download_state = yield download(source) 15 | Success(create_result(download_state)) 16 | end 17 | 18 | def download(source) 19 | YoutubeDL.download(source.url, **arguments(source)) 20 | .on_progress do |state:, **| 21 | progress_callback.progress(state.progress) 22 | end 23 | .on_complete do |state:, **| 24 | return Success(state) 25 | end 26 | .on_error do |state:, **| 27 | return handle_youtube_dl_error(state.error) 28 | end 29 | .call 30 | 31 | raise 'YoutubeDL finished without error but not complete either' 32 | end 33 | 34 | def arguments(source) 35 | { 36 | format: 'mp4,mp3', 37 | output: output_template(source), 38 | } 39 | end 40 | 41 | def output_template(source) 42 | (storage_dir(source) / '%(title)s.%(ext)s').to_s 43 | end 44 | 45 | def storage_dir(source) 46 | dir = settings.storage_dir / source.id.to_s.rjust(10, '0') 47 | dir.mkpath 48 | dir 49 | end 50 | 51 | def handle_youtube_dl_error(error) 52 | match = error.match(/STDERR:\n\n(.*)/m) 53 | if match 54 | handle_command_error(match[1]) 55 | else 56 | raise "Unexpected error: #{error}" unless match 57 | end 58 | end 59 | 60 | def handle_command_error(output) 61 | case output 62 | when /Unsupported URL:/ 63 | Failure(['downloads.fetcher.unsupported', output: output]) 64 | else 65 | Failure(['downloads.fetcher.unknown_error', output: output]) 66 | end 67 | end 68 | 69 | def create_result(download_state) 70 | result.new(**{ 71 | fetcher: 'youtube_dl', 72 | path: download_state.destination, 73 | author: download_state.info['uploader'], 74 | title: download_state.info['title'], 75 | thumbnail_url: download_state.info['thumbnail'], 76 | fetcher_information: download_state.info, 77 | }.compact) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/downloader/result.rb: -------------------------------------------------------------------------------- 1 | module Downloader 2 | class Result < Dry::Struct 3 | attribute :fetcher, Types::String 4 | attribute :path, Types::Pathname 5 | attribute :author, Types::String.meta(omittable: true) 6 | attribute :title, Types::String.meta(omittable: true) 7 | attribute :thumbnail_url, Types::String.meta(omittable: true) 8 | attribute :fetcher_information, Types::Hash 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/downloads/contract.rb: -------------------------------------------------------------------------------- 1 | module Downloads 2 | class Contract < ApplicationContract 3 | params do 4 | required(:path).filled(:string) 5 | required(:source_id).value(:integer) 6 | required(:fetcher).filled(:string) 7 | required(:fetcher_information).value(:hash) 8 | optional(:author).value(:string) 9 | optional(:title).value(:string) 10 | optional(:thumbnail_url).value(:string) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/downloads/create.rb: -------------------------------------------------------------------------------- 1 | module Downloads 2 | class Create 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | 'downloads.contract' 8 | ] 9 | 10 | def call(attrs) 11 | attrs = attrs.dup 12 | 13 | attrs = yield validate(attrs) 14 | file = yield get_file(attrs.delete(:path)) 15 | download = yield create_download(attrs: attrs, file: file) 16 | events.publish('downloads.created', download: download) 17 | Success(download) 18 | end 19 | 20 | def validate(attrs) 21 | Success(yield(contract.call(attrs).to_monad).to_h) 22 | end 23 | 24 | def get_file(path) 25 | pathname = Pathname(path) 26 | return Failure(:not_a_file) unless pathname.file? 27 | 28 | Success(pathname.open('r')) 29 | end 30 | 31 | def ensure_unique_path(path) 32 | if Download.by_path(path).any? 33 | Failure('downloads.create.path_exists') 34 | else 35 | Success(true) 36 | end 37 | end 38 | 39 | def create_download(attrs:, file:) 40 | Success(Download.create(attrs.merge(file: file))) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/downloads/destroy.rb: -------------------------------------------------------------------------------- 1 | module Downloads 2 | class Destroy 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | ] 8 | 9 | def call(download) 10 | download.destroy 11 | events.publish('downloads.destroyed', download: download) 12 | Success() 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/downloads/file_uploader.rb: -------------------------------------------------------------------------------- 1 | module Downloads 2 | class FileUploader < Shrine 3 | plugin :download_endpoint, prefix: 'download', 4 | host: Rails.application.routes.default_url_options[:host], 5 | disposition: 'attachment' 6 | plugin :determine_mime_type, analyzer: :mimemagic 7 | 8 | def generate_location(io, record: nil, **context) 9 | [ 10 | 'downloads', 11 | sanitize_filename(record.author.presence || 'no_author'), 12 | sanitize_filename(context[:metadata]['filename']), 13 | ].join('/') 14 | end 15 | 16 | private 17 | 18 | def sanitize_filename(name) 19 | name.strip.gsub(/[^0-9A-Za-z.\-]+/, '_') 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/events/logger.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | class Logger 3 | include Podify::Import['events', 'logger'] 4 | 5 | def subscribe 6 | events.subscribe self 7 | end 8 | 9 | def unsubscribe 10 | events.unsubscribe self 11 | end 12 | 13 | def respond_to_missing?(method, *) 14 | method.to_s.start_with?('on_') 15 | end 16 | 17 | private 18 | 19 | def method_missing(method, event) 20 | log_event(event) 21 | end 22 | 23 | def log_event(event) 24 | logger.debug event: { 25 | id: event.id, 26 | payload: clean_payload(event.payload), 27 | } 28 | end 29 | 30 | def clean_payload(payload) 31 | return payload unless payload.is_a?(Hash) 32 | 33 | payload.to_h do |k, v| 34 | v = v.id if v.is_a?(Sequel::Model) 35 | v = clean_payload(v) 36 | 37 | [k, v] 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/events/logger/listener.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | class Logger 3 | class Listener 4 | include Podify::Import['events.logger'] 5 | 6 | def respond_to_missing?(method, *) 7 | method.to_s.start_with?('on_') 8 | end 9 | 10 | def method_missing(method, event) 11 | logger.log_event(event) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/events/publisher.rb: -------------------------------------------------------------------------------- 1 | module Events 2 | class Publisher 3 | include Dry::Events::Publisher[:podify] 4 | 5 | register_event('feeds.created') 6 | 7 | register_event('users.created') 8 | 9 | register_event('requests.created') 10 | register_event('requests.updated') 11 | register_event('requests.destroyed') 12 | 13 | register_event('sources.created') 14 | register_event('sources.destroyed') 15 | 16 | register_event('downloads.created') 17 | register_event('downloads.destroyed') 18 | 19 | register_event('downloader.fetch_source.fetched') 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/expand_path.rb: -------------------------------------------------------------------------------- 1 | class ExpandPath 2 | include Dry::Monads[:result, :do] 3 | include Dry::Monads::Do.for(:call) 4 | 5 | def call(path) 6 | path = Pathname(path) 7 | Success(path.realpath) 8 | rescue 9 | Failure(:does_not_exist) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/feeds/contract.rb: -------------------------------------------------------------------------------- 1 | module Feeds 2 | class Contract < ApplicationContract 3 | params do 4 | required(:name).filled(:string) 5 | required(:user_id).value(:integer) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/feeds/create.rb: -------------------------------------------------------------------------------- 1 | module Feeds 2 | class Create 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | 'feeds.contract', 8 | ] 9 | 10 | def call(attrs) 11 | attrs = yield(contract.call(attrs).to_monad).to_h 12 | feed = yield create_feed(attrs) 13 | events.publish('feeds.created', feed: feed) 14 | Success(feed) 15 | end 16 | 17 | def create_feed(attrs) 18 | Success(Feed.create(**attrs, type: 'manual')) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/feeds/destroy.rb: -------------------------------------------------------------------------------- 1 | module Feeds 2 | class Destroy 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | destroy_request: 'requests.destroy', 8 | ] 9 | 10 | def call(feed) 11 | yield ensure_may_destroy(feed) 12 | DB.transaction do 13 | yield destroy_requests(feed.requests) 14 | feed.destroy 15 | end 16 | events.publish('feeds.destroyed', feed: feed) 17 | Success() 18 | end 19 | 20 | def ensure_may_destroy(feed) 21 | if feed.type == 'all' 22 | Failure('feeds.destroy.special_feed') 23 | else 24 | Success() 25 | end 26 | end 27 | 28 | def destroy_requests(requests) 29 | requests.each do |request| 30 | yield destroy_request.call(request) 31 | end 32 | Success() 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/feeds/order.rb: -------------------------------------------------------------------------------- 1 | module Feeds 2 | class Order 3 | include Dry::Monads[:result, :do] 4 | 5 | def call(user:, feed_ids:) 6 | feeds = get_ordered_feeds(dataset: user.feeds_dataset, feed_ids: feed_ids) 7 | update_positions(feeds) 8 | Success(feeds) 9 | end 10 | 11 | def get_ordered_feeds(dataset:, feed_ids:) 12 | feeds_by_id = dataset 13 | .where(id: feed_ids) 14 | .all 15 | .group_by(&:id) 16 | feed_ids.flat_map { |id| feeds_by_id[id] || [] } 17 | end 18 | 19 | def update_positions(feeds) 20 | feeds.each_with_index do |feed, index| 21 | feed.update(position: index) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/requests/contract.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | class Contract < ApplicationContract 3 | params do 4 | required(:user_id).value(:integer) 5 | required(:source_id).value(:integer) 6 | required(:feed_id).value(:integer) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/requests/create.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | class Create 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | 'requests.contract', 8 | ] 9 | 10 | def call(attrs) 11 | attrs = yield(contract.call(attrs).to_monad).to_h 12 | yield ensure_uniqueness(attrs) 13 | request = yield create_request(attrs) 14 | events.publish('requests.created', request: request) 15 | Success(request) 16 | end 17 | 18 | def ensure_uniqueness(attrs) 19 | return Failure('requests.create.exists') if Request.where(attrs).count > 0 20 | Success() 21 | end 22 | 23 | def create_request(attrs) 24 | Success(Request.create(attrs)) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/requests/create_for_url.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | class CreateForUrl 3 | include Dry::Monads[:result, :do, :maybe] 4 | 5 | include Podify::Import[ 6 | create_request: 'requests.create', 7 | find_or_create_source: 'sources.find_or_create', 8 | ] 9 | 10 | def call(user:, feed:, url:) 11 | DB.transaction do 12 | source = yield find_or_create_source.call(url) 13 | request = yield create_request.call(user_id: user.id, source_id: source.id, feed_id: feed.id) 14 | Success(request) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/requests/destroy.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | class Destroy 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | ] 8 | 9 | def call(request) 10 | request.destroy 11 | events.publish('requests.destroyed', request: request) 12 | Success() 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/requests/update.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | class Update 3 | include Dry::Monads[:result, :maybe, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | 'requests.contract', 8 | ] 9 | 10 | def call(request, feed_id:) 11 | feed = yield get_feed(request, feed_id) 12 | request.update(feed_id: feed.id) 13 | events.publish('requests.updated', request: request) 14 | Success() 15 | end 16 | 17 | def get_feed(request, feed_id) 18 | Maybe(request.user.feeds_dataset[feed_id]).or( 19 | Failure('requests.update.feed_not_found') 20 | ) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/sources/contract.rb: -------------------------------------------------------------------------------- 1 | module Sources 2 | class Contract < ApplicationContract 3 | params do 4 | required(:url).filled(:string) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/sources/create.rb: -------------------------------------------------------------------------------- 1 | module Sources 2 | class Create 3 | include Dry::Monads[:result, :do] 4 | include Dry::Effects.Defer 5 | 6 | include Podify::Import[ 7 | 'events', 8 | 'sources.contract', 9 | 'downloader.fetch_source_job', 10 | ] 11 | 12 | def call(attrs) 13 | attrs = yield(contract.call(attrs).to_monad).to_h 14 | source = yield create_source(attrs) 15 | events.publish('sources.created', source: source) 16 | later { fetch_source_job.perform_async(source.id) } # to get out of any transactions before the job runs 17 | Success(source) 18 | end 19 | 20 | def create_source(attrs) 21 | Success(Source.create(attrs)) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/sources/destroy.rb: -------------------------------------------------------------------------------- 1 | module Sources 2 | class Destroy 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | destroy_download: 'downloads.destroy', 8 | destroy_request: 'requests.destroy', 9 | ] 10 | 11 | def call(source) 12 | DB.transaction do 13 | yield destroy_requests(source.requests) 14 | yield destroy_downloads(source.downloads) 15 | source.destroy 16 | end 17 | events.publish('sources.destroyed', source: source) 18 | Success() 19 | end 20 | 21 | def destroy_downloads(downloads) 22 | downloads.each do |download| 23 | yield destroy_download.call(download) 24 | end 25 | Success() 26 | end 27 | 28 | def destroy_requests(requests) 29 | requests.each do |request| 30 | yield destroy_request.call(request) 31 | end 32 | Success() 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/sources/find_or_create.rb: -------------------------------------------------------------------------------- 1 | module Sources 2 | class FindOrCreate 3 | include Dry::Monads[:result, :do, :maybe] 4 | 5 | include Podify::Import[ 6 | 'sources.create', 7 | ] 8 | 9 | def call(url) 10 | find(url).or do 11 | source = yield create.call(url: url) 12 | Success(source) 13 | end 14 | end 15 | 16 | def find(url) 17 | Maybe(Source.by_url(url).first) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/sources/import_directory.rb: -------------------------------------------------------------------------------- 1 | module Sources 2 | class ImportDirectory 3 | include Dry::Monads[:result, :do] 4 | include Dry::Monads::Do.for(:call) 5 | 6 | include Podify::Import['expand_path', 'sources.import_file'] 7 | 8 | def call(path) 9 | path = yield expand_path.call(path) 10 | yield assert_directory(path) 11 | 12 | Success( 13 | all_files(path).map do |file| 14 | import_file.call(file) 15 | end.to_a 16 | ) 17 | end 18 | 19 | def assert_directory(path) 20 | if path.directory? 21 | Success() 22 | else 23 | Failure(:not_a_directory) 24 | end 25 | end 26 | 27 | def all_files(path) 28 | path.find.lazy.select(&:file?) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/sources/import_file.rb: -------------------------------------------------------------------------------- 1 | module Sources 2 | class ImportFile 3 | include Dry::Monads[:result, :do, :maybe] 4 | include Dry::Effects::Handler.Defer 5 | 6 | include Podify::Import[ 7 | 'expand_path', 8 | find_or_create_source: 'sources.find_or_create', 9 | ] 10 | 11 | def call(path) 12 | with_defer do 13 | DB.transaction do 14 | path = yield expand_path.call(path) 15 | yield assert_file(path) 16 | source = yield get_source(path) 17 | next Success(source) 18 | end 19 | end 20 | end 21 | 22 | def assert_file(path) 23 | return Failure(:not_a_file) unless path.file? 24 | Success() 25 | end 26 | 27 | def get_source(path) 28 | source = yield find_or_create_source.call("file://#{path}") 29 | Success(source) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/types.rb: -------------------------------------------------------------------------------- 1 | module Types 2 | include Dry.Types() 3 | 4 | Pathname = Types.Instance(Pathname) 5 | 6 | module Coercible 7 | Pathname = Types::Pathname.constructor { |path| Pathname(path) } 8 | 9 | YesNoBoolean = Types.Constructor(TrueClass) do |input| 10 | next input if input === true || input === false 11 | 12 | case input.to_s.downcase 13 | when 'yes', 'y', 'true' 14 | true 15 | when 'no', 'n', 'false' 16 | false 17 | else 18 | raise ArgumentError, "Invalid input to Types::Coercible::YesNoBoolean: #{input.inspect}" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/users/contract.rb: -------------------------------------------------------------------------------- 1 | module Users 2 | class Contract < ApplicationContract 3 | params do 4 | required(:email).filled(:string) 5 | required(:password).filled(:string) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/users/create.rb: -------------------------------------------------------------------------------- 1 | module Users 2 | class Create 3 | include Dry::Monads[:result, :do] 4 | 5 | include Podify::Import[ 6 | 'events', 7 | 'users.contract', 8 | ] 9 | 10 | def call(attrs) 11 | attrs = yield(contract.call(attrs).to_monad).to_h 12 | user = yield create_user(attrs) 13 | events.publish('users.created', user: user) 14 | Success(user) 15 | end 16 | 17 | def create_user(attrs) 18 | Success(DeviseUser.create(attrs)) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/users/create_initial.rb: -------------------------------------------------------------------------------- 1 | module Users 2 | class CreateInitial 3 | include Dry::Monads[:result, :do, :maybe] 4 | 5 | include Podify::Import[ 6 | 'users.create', 7 | 'settings', 8 | ] 9 | 10 | def call 11 | params = { 12 | email: settings.initial_user_email, 13 | password: settings.initial_user_password, 14 | } 15 | 16 | return Success(nil) if params.compact.empty? 17 | 18 | User.db.transaction do 19 | User.dataset.lock('EXCLUSIVE') 20 | 21 | Maybe(User.by_email(settings.initial_user_email)).fmap do |existing| 22 | return Success(existing) 23 | end 24 | 25 | create.call(params).fmap do |user| 26 | return Success(user) 27 | end.or do |failure| 28 | return Failure("Failed creating the initial user: #{failure.errors.to_h.map { |*a| a.join(' ') }.join(', ')}") 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/log/.keep -------------------------------------------------------------------------------- /logo/attributions: -------------------------------------------------------------------------------- 1 | Podcast icon: Font Awesome 2 | -------------------------------------------------------------------------------- /logo/podcast-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "podify", 3 | "private": true, 4 | "dependencies": { 5 | "@chenfengyuan/vue-qrcode": "^1.0.2", 6 | "@fortawesome/fontawesome-free": "^5.15.1", 7 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 8 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 9 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 10 | "@fortawesome/vue-fontawesome": "^0.1.10", 11 | "@rails/ujs": "^6.0.3", 12 | "@rails/webpacker": "4.2.2", 13 | "actioncable-vue": "^2.3.2", 14 | "apollo-cache-inmemory": "^1.6.6", 15 | "apollo-client": "^2.6.10", 16 | "apollo-link-http": "^1.5.17", 17 | "bootstrap": "4.4.1", 18 | "bootstrap-vue": "^2.20.1", 19 | "es6-promise": "^4.2.8", 20 | "graphql": "^15.4.0", 21 | "graphql-tag": "^2.11.0", 22 | "hamburgers": "^1.1.3", 23 | "jquery": "~3.4.1", 24 | "plyr": "^3.6.3", 25 | "popper.js": "^1.16.1", 26 | "vue": "^2.6.12", 27 | "vue-apollo": "^3.0.5", 28 | "vue-clipboard2": "^0.3.1", 29 | "vue-loader": "^15.9.5", 30 | "vue-loading-overlay": "^3.4.2", 31 | "vue-router": "^3.5.0", 32 | "vue-template-compiler": "^2.6.12", 33 | "vuedraggable": "^2.24.3", 34 | "vuex": "^3.6.0" 35 | }, 36 | "version": "0.1.0", 37 | "devDependencies": { 38 | "webpack-dev-server": "^3.11.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /spec/factories/feeds.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :feed do 3 | name { generate(:feed_name) } 4 | end 5 | 6 | sequence :feed_name do |n| 7 | "Feed #{n}" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/sources.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :source do 3 | url { generate(:url) } 4 | end 5 | 6 | sequence :url do |n| 7 | "https://youtube.com/watch?v=#{n}" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :user do 3 | email { generate(:email) } 4 | end 5 | 6 | sequence :email do |n| 7 | "#{n}@example.com" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/files/example.mp4: -------------------------------------------------------------------------------- 1 | dummy mp4 -------------------------------------------------------------------------------- /spec/integration/lib/requests/create_for_url_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Requests::CreateForUrl do 4 | include Dry::Effects::Handler.Defer 5 | 6 | let(:url) { 'https://example.com/some-video' } 7 | let(:user) { create(:user) } 8 | let(:feed) { create(:feed, user: user) } 9 | 10 | let(:result) { subject.call(user: user, feed: feed, url: url) } 11 | let(:request) { result.value! } 12 | 13 | context 'when a source with the url does not exist yet' do 14 | it 'creates a source and request' do 15 | expect { with_defer { result } } 16 | .to change(Source, :count).by(1) 17 | .and change(Request, :count).by(1) 18 | expect(result).to be_success 19 | expect(request.source.url).to eq(url) 20 | expect(request.user).to eq(user) 21 | end 22 | end 23 | 24 | context 'when a source with the url already exists' do 25 | before do 26 | create(:source, url: url) 27 | end 28 | 29 | it 'creates the request' do 30 | expect { result } 31 | .to change(Source, :count).by(0) 32 | .and change(Request, :count).by(1) 33 | expect(result).to be_success 34 | expect(request.source.url).to eq(url) 35 | expect(request.user).to eq(user) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/integration/lib/sources/import_file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Sources::ImportFile do 4 | let(:path) { file_fixture('example.mp4') } 5 | let(:result) { subject.call(path) } 6 | 7 | context 'when a source with the file does not exist yet' do 8 | it 'creates a source' do 9 | expect { result }.to change(Source, :count).by(1) 10 | expect(result).to be_success 11 | expect(result.value!.url).to eq("file://#{path.expand_path.realpath}") 12 | end 13 | end 14 | 15 | context 'when a source with the file already exists' do 16 | before do 17 | subject.call(path) 18 | end 19 | 20 | it 'returns the source' do 21 | expect { result }.not_to change(Source, :count) 22 | expect(result).to be_success 23 | expect(result.value!.url).to eq("file://#{path.expand_path.realpath}") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | ENV['RAILS_ENV'] ||= 'test' 3 | require File.expand_path('../config/environment', __dir__) 4 | # Prevent database truncation if the environment is production 5 | abort("The Rails environment is running in production mode!") if Rails.env.production? 6 | require 'rspec/rails' 7 | # Add additional requires below this line. Rails is not loaded until this point! 8 | 9 | # Requires supporting ruby files with custom matchers and macros, etc, in 10 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 11 | # run as spec files by default. This means that files in spec/support that end 12 | # in _spec.rb will both be required and run as specs, causing the specs to be 13 | # run twice. It is recommended that you do not name files matching this glob to 14 | # end with _spec.rb. You can configure this pattern with the --pattern 15 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 16 | # 17 | # The following line is provided for convenience purposes. It has the downside 18 | # of increasing the boot-up time by auto-requiring all files in the support 19 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 20 | # require only the support files necessary. 21 | # 22 | # Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } 23 | 24 | FactoryBot.define do 25 | to_create(&:save) 26 | end 27 | 28 | RSpec.configure do |config| 29 | config.include FactoryBot::Syntax::Methods 30 | 31 | 32 | # Remove this line to enable support for ActiveRecord 33 | config.use_active_record = false 34 | 35 | config.around(:each) do |example| 36 | DB.transaction(rollback: :always, auto_savepoint: true) { example.run } 37 | end 38 | 39 | # If you enable ActiveRecord support you should unncomment these lines, 40 | # note if you'd prefer not to run each example within a transaction, you 41 | # should set use_transactional_fixtures to false. 42 | # 43 | # config.fixture_path = "#{::Rails.root}/spec/fixtures" 44 | # config.use_transactional_fixtures = true 45 | 46 | # RSpec Rails can automatically mix in different behaviours to your tests 47 | # based on their file location, for example enabling you to call `get` and 48 | # `post` in specs under `spec/controllers`. 49 | # 50 | # You can disable this behaviour by removing the line below, and instead 51 | # explicitly tag your specs with their type, e.g.: 52 | # 53 | # RSpec.describe UsersController, type: :controller do 54 | # # ... 55 | # end 56 | # 57 | # The different available types are documented in the features, such as in 58 | # https://relishapp.com/rspec/rspec-rails/docs 59 | config.infer_spec_type_from_file_location! 60 | 61 | # Filter lines from Rails gems in backtraces. 62 | config.filter_rails_from_backtrace! 63 | # arbitrary gems may also be filtered via: 64 | # config.filter_gems_from_backtrace("gem name") 65 | end 66 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/tmp/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podify-org/podify/b656a910b25b94948581edd15d29bf9ff36ad64e/vendor/.keep --------------------------------------------------------------------------------