diff --git a/app/controllers/feeds_controller.rb b/app/controllers/feeds_controller.rb index 8eba4651d..151cb23ad 100644 --- a/app/controllers/feeds_controller.rb +++ b/app/controllers/feeds_controller.rb @@ -9,8 +9,9 @@ def index # GET /feeds/1 def show - @feed.assign_attributes(feed_params_with_apple) + @feed.assign_attributes(feed_params) authorize @feed + @apple_show_options = get_apple_show_options(@feed) end # GET /feeds/new @@ -22,19 +23,25 @@ def new @feed.clear_attribute_changes(%i[file_name podcast_id private slug]) end + def get_apple_show_options(feed) + if feed.apple? && feed.apple_config&.key + feed.apple_show_options + end + end + def new_apple @feed = Feeds::AppleSubscription.new(podcast: @podcast, private: true) @feed.build_apple_config @feed.apple_config.build_key authorize @feed - @feed.assign_attributes(feed_params_with_apple) + @feed.assign_attributes(feed_params) render "new" end # POST /feeds def create - @feed = @podcast.feeds.new(feed_params_with_apple) + @feed = @podcast.feeds.new(feed_params) @feed.slug = "" if @feed.slug.nil? authorize @feed @@ -114,10 +121,6 @@ def feed_params params.fetch(:feed, {}).permit(:slug).merge(nilified_feed_params) end - def feed_params_with_apple - params.fetch(:feed, {}).permit(:slug).merge(nilified_feed_params).merge(apple_params) - end - def nilified_feed_params nilify params.fetch(:feed, {}).permit( :lock_version, @@ -144,20 +147,13 @@ def nilified_feed_params :paid, :sonic_id, :type, + :apple_show_id, itunes_category: [], itunes_subcategory: [], feed_tokens_attributes: %i[id label token _destroy], feed_images_attributes: %i[id original_url size alt_text caption credit _destroy _retry], - itunes_images_attributes: %i[id original_url size alt_text caption credit _destroy _retry] - ) - end - - def apple_params - nilify params.fetch(:feed, {}).permit( - apple_config_attributes: { - id: :id, - key_attributes: %i[id provider_id key_id key_pem_b64] - } + itunes_images_attributes: %i[id original_url size alt_text caption credit _destroy _retry], + apple_config_attributes: [:id, :publish_enabled, :sync_blocks_rss, {key_attributes: %i[id provider_id key_id key_pem_b64]}] ) end end diff --git a/app/javascript/controllers/apple_key_controller.js b/app/javascript/controllers/apple_key_controller.js index 0a32b592c..239143f88 100644 --- a/app/javascript/controllers/apple_key_controller.js +++ b/app/javascript/controllers/apple_key_controller.js @@ -30,14 +30,7 @@ export default class extends Controller { const keyId = fields[1].split(".")[0] this.providerTargets.forEach((target) => (target.value = provider)) - this.providerTarget.disabled = false - this.providerTarget.focus() - this.providerTarget.disabled = true - this.keyTargets.forEach((target) => (target.value = keyId)) - this.keyTarget.disabled = false - this.keyTarget.focus() - this.keyTarget.disabled = true } convertKeyToB64(fileText) { diff --git a/app/models/apple/key.rb b/app/models/apple/key.rb index 6f72616a7..af2370b8b 100644 --- a/app/models/apple/key.rb +++ b/app/models/apple/key.rb @@ -8,6 +8,16 @@ class Key < ApplicationRecord validate :provider_id_is_valid, if: :provider_id? validate :ec_key_format, if: :key_pem_b64? + validate :must_have_working_key + + def must_have_working_key + return if Rails.env.test? + api = Apple::Api.from_key(self) + Apple::Show.apple_shows_json(api) + rescue => err + logger.error(err) + errors.add(:key_id, "must have a working Apple key") + end def provider_id_is_valid if provider_id.include?("_") diff --git a/app/models/apple/show.rb b/app/models/apple/show.rb index 61b0ab3a8..be1b979b0 100644 --- a/app/models/apple/show.rb +++ b/app/models/apple/show.rb @@ -8,18 +8,29 @@ class Show :private_feed, :api + def self.apple_shows_json(api) + api.get_paged_collection("shows") + end + def self.apple_episode_json(api, show_id) api.get_paged_collection("shows/#{show_id}/episodes") end def self.connect_existing(apple_show_id, apple_config) - api = Apple::Api.from_apple_config(apple_config) - - SyncLog.log!(feeder_id: apple_config.public_feed.id, - feeder_type: :feeds, - sync_completed_at: Time.now.utc, - external_id: apple_show_id) + if (sl = SyncLog.find_by(feeder_id: apple_config.public_feed.id, feeder_type: :feeds)) + if apple_show_id.blank? + return sl.destroy! + elsif sl.external_id != apple_show_id + sl.update!(external_id: apple_show_id) + end + else + SyncLog.log!(feeder_id: apple_config.public_feed.id, + feeder_type: :feeds, + sync_completed_at: Time.now.utc, + external_id: apple_show_id) + end + api = Apple::Api.from_apple_config(apple_config) new(api: api, public_feed: apple_config.public_feed, private_feed: apple_config.private_feed) diff --git a/app/models/feeds/apple_subscription.rb b/app/models/feeds/apple_subscription.rb index f01ee467b..6e1d9a96b 100644 --- a/app/models/feeds/apple_subscription.rb +++ b/app/models/feeds/apple_subscription.rb @@ -14,6 +14,8 @@ class Feeds::AppleSubscription < Feed after_create :republish_public_feed + after_save_commit :update_apple_show + has_one :apple_config, class_name: "::Apple::Config", dependent: :destroy, autosave: true, validate: true, inverse_of: :feed accepts_nested_attributes_for :apple_config, allow_destroy: true, reject_if: :all_blank @@ -23,6 +25,14 @@ class Feeds::AppleSubscription < Feed validate :must_be_private validate :must_have_token + # for soft delete, need a unique slug to be able to make another + def paranoia_destroy_attributes + { + deleted_at: current_time_from_proper_timezone, + slug: "#{slug} - #{Time.now.to_i}" + } + end + def set_defaults self.slug ||= DEFAULT_FEED_SLUG self.title ||= DEFAULT_TITLE @@ -35,6 +45,25 @@ def set_defaults super end + def update_apple_show + if previous_changes[:apple_show_id] + Apple::Show.connect_existing(apple_show_id, apple_config) + end + end + + def apple_show_options + used_ids = Feed.apple.distinct.where("id != ?", id).pluck(:apple_show_id).compact + api = Apple::Api.from_apple_config(apple_config) + shows_json = Apple::Show.apple_shows_json(api) || [] + shows_json + .filter { |sj| sj["attributes"]["publishingState"] != "ARCHIVED" } + .filter { |sj| !used_ids.include?(sj["id"]) } + .map { |sj| ["#{sj["id"]} (#{sj["attributes"]["title"]})", sj["id"]] } + rescue => err + logger.error(err) + [] + end + def guess_audio_format default_feed_audio_format || episode_audio_format || DEFAULT_AUDIO_FORMAT end diff --git a/app/policies/apple/config_policy.rb b/app/policies/apple/config_policy.rb index 08245787c..48de881f8 100644 --- a/app/policies/apple/config_policy.rb +++ b/app/policies/apple/config_policy.rb @@ -12,6 +12,6 @@ def create? end def update? - false + FeedPolicy.new(token, resource.feed).update? end end diff --git a/app/policies/feed_policy.rb b/app/policies/feed_policy.rb index 6434f2d6b..5b60bf395 100644 --- a/app/policies/feed_policy.rb +++ b/app/policies/feed_policy.rb @@ -12,14 +12,14 @@ def create? end def new_apple? - create? + update? end def update? - PodcastPolicy.new(token, resource.podcast).update? + PodcastPolicy.new(token, resource.podcast).update? && !resource.edit_locked? end def destroy? - resource.custom? && update? && !resource.apple? + resource.custom? && update? end end diff --git a/app/views/feeds/_form.html.erb b/app/views/feeds/_form.html.erb index 46b36ec10..8575394ce 100644 --- a/app/views/feeds/_form.html.erb +++ b/app/views/feeds/_form.html.erb @@ -6,14 +6,24 @@ <%= form_with(url: url, model: model, method: method, html: {autocomplete: "off"}, data: data) do |form| %>
+ <% if feed.edit_locked? %> +
+ +
+ <% end %>
<% if feed.default? %> <%= render "form_main", podcast: podcast, feed: feed, form: form %> <%= render "form_audio_format", podcast: podcast, feed: feed, form: form %> <% elsif apple_feed?(feed) %> <%= render "form_apple_config", podcast: podcast, feed: feed, form: form %> - <%= render "form_audio_format", podcast: podcast, feed: feed, form: form %> - <%= render "form_ad_zones", podcast: podcast, feed: feed, form: form %> + <% if feed.persisted? %> + <%= render "form_audio_format", podcast: podcast, feed: feed, form: form %> + <%= render "form_ad_zones", podcast: podcast, feed: feed, form: form %> + <% end %> <% else %> <%# custom feeds %> <%= render "form_main", podcast: podcast, feed: feed, form: form %> <%= render "form_auth", podcast: podcast, feed: feed, form: form %> diff --git a/app/views/feeds/_form_apple_config.html.erb b/app/views/feeds/_form_apple_config.html.erb index f0ec19927..bb1b7bf3b 100644 --- a/app/views/feeds/_form_apple_config.html.erb +++ b/app/views/feeds/_form_apple_config.html.erb @@ -6,38 +6,66 @@

<%= t(".description") %>

- <%= t(".guide_link") %> + <%= t(".guide_link").html_safe %>
<%= form.fields_for :apple_config do |config_form| %> <%= config_form.fields_for :key do |key_fields| %> <% unless config_form.object.persisted? %> - <%= key_fields.file_field :key_file, class: "mb-4 form-control", accept: ".p8, .pem", data: {action: "apple-key#convertFileToKey"} %> + <%= key_fields.file_field :key_file, class: "mb-4 form-control", accept: ".p8, .pem", data: {action: "apple-key#convertFileToKey"}, required: true %> <%= key_fields.hidden_field :key_pem_b64, data: {apple_key_target: "pem"} %> + <% else %> +
<%= t(".key_uploaded") %>
+
+ <%= key_fields.text_field :provider_id, disabled: true, data: {apple_key_target: "provider"} %> + <%= key_fields.label :provider_id, "Provider ID" %> +
+
+ <%= key_fields.text_field :key_id, disabled: true, data: {apple_key_target: "key"} %> + <%= key_fields.label :key_id, "Apple Key" %> +
<% end %> -
- <%= key_fields.text_field :provider_id, disabled: true, data: {apple_key_target: "provider"} %> - <%= key_fields.label :provider_id, "Provider ID" %> + <%= key_fields.hidden_field :provider_id, data: {apple_key_target: "provider"} %> + <%= key_fields.hidden_field :key_id, data: {apple_key_target: "key"} %> + <% end %> + <% if config_form.object.persisted? %> +
+
+ <%= config_form.check_box :publish_enabled %> +
+ <%= config_form.label :publish_enabled %> + <%= help_text t(".help.publish_enabled") %> +
+
+
+ <%= config_form.check_box :sync_blocks_rss %> +
+ <%= config_form.label :sync_blocks_rss %> + <%= help_text t(".help.sync_blocks_rss") %> +
+
+ <% end %> + <% end %> + <% if form.object.apple_config&.persisted? %> +
- <%= key_fields.text_field :key_id, disabled: true, data: {apple_key_target: "key"} %> - <%= key_fields.label :key_id, "Apple Key" %> + <%= form.select :apple_show_id, @apple_show_options, {include_blank: true} %> + <%= form.label :apple_show_id, "Apple Show ID" %>
+
- <%= key_fields.hidden_field :provider_id, data: {apple_key_target: "provider"} %> - <%= key_fields.hidden_field :key_id, data: {apple_key_target: "key"} %> - <% end %> +
+
+ <%= form.number_field :display_episodes_count %> + <%= form.label :display_episodes_count %> + <%= field_help_text t(".help.display_episodes_count") %> +
+
<% end %> <%= form.hidden_field :type, value: "Feeds::AppleSubscription" %> -
-
- <%= form.number_field :display_episodes_count %> - <%= form.label :display_episodes_count %> - <%= field_help_text t(".help.display_episodes_count") %> -
-
diff --git a/app/views/feeds/_tabs.html.erb b/app/views/feeds/_tabs.html.erb index ff87ce963..81d2e1f05 100644 --- a/app/views/feeds/_tabs.html.erb +++ b/app/views/feeds/_tabs.html.erb @@ -12,8 +12,7 @@
<%= link_to "Add a Feed", new_podcast_feed_path(podcast), class: "btn btn-success flex-grow-1" %> - <%# FIXME %> - <% if false && @feeds.none?(&:apple?) %> + <% if @feeds.none?(&:apple?) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index af79a7244..4e9753270 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -330,6 +330,9 @@ en: application: field_copy_tooltip: Copied! label: + apple/config: + publish_enabled: Enable publishing to Apple Podcasts Connect + sync_blocks_rss: Episodes must publish to Apple before the public feed episode: ad_breaks: Ad Breaks author_email: Author Email @@ -779,10 +782,13 @@ en: ad_zones: Control the types of ads that should be stitched into episodes in this feed. form_apple_config: title: Apple Subscriptions - description: In order to publish episodes through Apple's Podcast Connect API, ensure you've delegated access to the PRX account in Podcast Connect. - guide_link: Check out our guide to step you through the setup. + description: Configure your credentials to publish episodes through Apple's Podcast Connect API + guide_link: Check out our guide to step you through the setup. + key_uploaded: Apple API Key Uploaded help: display_episodes_count: You can optionally limit the number of episodes that Paid Subscribers will see in Apple Podcasts. Leave this field blank to include all. + publish_enabled: Enable automatically publishing and updating episodes in this feed to Apple? + sync_blocks_rss: Block publishing to the default feed RSS until the Apple episode is published? form_audio_format: title: Audio Format help: @@ -831,6 +837,8 @@ en: title: Feed Status form: <<: *form + help: + locked: This feed is locked, to make changes please contact PRX support. helper: episode_offset_options: "-300": 5 minutes early diff --git a/db/migrate/20241023012600_add_apple_show_id_to_feeds.rb b/db/migrate/20241023012600_add_apple_show_id_to_feeds.rb new file mode 100644 index 000000000..e9bd8b44c --- /dev/null +++ b/db/migrate/20241023012600_add_apple_show_id_to_feeds.rb @@ -0,0 +1,15 @@ +class AddAppleShowIdToFeeds < ActiveRecord::Migration[7.2] + def change + add_column :feeds, :apple_show_id, :string + add_index :feeds, :apple_show_id + + reversible do |dir| + dir.up do + Feeds::AppleSubscription.all.each do |feed| + apple_id = feed.apple_sync_log&.external_id + feed.update_attribute!(:apple_show_id, apple_id) + end + end + end + end +end diff --git a/db/migrate/20241023211041_add_edit_locked_to_feeds.rb b/db/migrate/20241023211041_add_edit_locked_to_feeds.rb new file mode 100644 index 000000000..6b49873f6 --- /dev/null +++ b/db/migrate/20241023211041_add_edit_locked_to_feeds.rb @@ -0,0 +1,5 @@ +class AddEditLockedToFeeds < ActiveRecord::Migration[7.2] + def change + add_column :feeds, :edit_locked, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 235f715d7..211f24543 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_02_215949) do +ActiveRecord::Schema[7.2].define(version: 2024_10_23_211041) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -239,6 +239,9 @@ t.datetime "deleted_at", precision: nil t.integer "lock_version", default: 0, null: false t.string "type" + t.string "apple_show_id" + t.boolean "edit_locked" + t.index ["apple_show_id"], name: "index_feeds_on_apple_show_id" t.index ["podcast_id", "slug"], name: "index_feeds_on_podcast_id_and_slug", unique: true, where: "(slug IS NOT NULL)" t.index ["podcast_id"], name: "index_feeds_on_podcast_id" t.index ["podcast_id"], name: "index_feeds_on_podcast_id_default", unique: true, where: "(slug IS NULL)" diff --git a/test/controllers/feeds_controller_test.rb b/test/controllers/feeds_controller_test.rb index 8d32639ac..17a078c72 100644 --- a/test/controllers/feeds_controller_test.rb +++ b/test/controllers/feeds_controller_test.rb @@ -3,6 +3,7 @@ class FeedsControllerTest < ActionDispatch::IntegrationTest let(:podcast) { create(:podcast, prx_account_uri: "/api/v1/accounts/123") } let(:feed) { create(:feed, podcast: podcast, private: false) } + let(:locked_feed) { create(:feed, podcast: podcast, private: false, edit_locked: true) } let(:private_feed) { create(:private_feed, podcast: podcast) } let(:update_params) { {url: "https://prx.org/a_public_url", display_episodes_count: 5} } let(:create_params) { {podcast: podcast, slug: "new_feed", title: "new title", private: false} } @@ -60,6 +61,10 @@ class FeedsControllerTest < ActionDispatch::IntegrationTest patch podcast_feed_url(podcast, feed), params: {feed: update_params} assert_response :forbidden end + test "should block update on locked feed" do + patch podcast_feed_url(podcast, locked_feed), params: {feed: update_params} + assert_response :forbidden + end test "should update feed" do patch podcast_feed_url(podcast, feed), params: {feed: update_params} diff --git a/test/models/apple/show_test.rb b/test/models/apple/show_test.rb index 3af027d19..9b0c98638 100644 --- a/test/models/apple/show_test.rb +++ b/test/models/apple/show_test.rb @@ -172,6 +172,15 @@ apple_publisher = Apple::Publisher.from_apple_config(apple_config.reload) assert_equal apple_publisher.show.apple_id, "some_apple_id" end + + it "should take in a new apple show id" do + apple_config.save! + apple_show = Apple::Show.connect_existing("some_apple_id", apple_config) + assert_equal apple_show.apple_id, "some_apple_id" + apple_show = Apple::Show.connect_existing("another_apple_id", apple_config) + apple_show.public_feed.reload + assert_equal apple_show.apple_id, "another_apple_id" + end end describe "#apple_id" do @@ -220,9 +229,8 @@ apple_show.public_feed.reload apple_show.stub(:create_or_update_show, raises_exception) do - sync = nil assert_raises(Apple::ApiError) do - sync = apple_show.sync! + apple_show.sync! end assert_nil apple_show.sync_log end diff --git a/test/models/feed/apple_subscription_test.rb b/test/models/feed/apple_subscription_test.rb index 925ac881b..3dd65d340 100644 --- a/test/models/feed/apple_subscription_test.rb +++ b/test/models/feed/apple_subscription_test.rb @@ -124,6 +124,12 @@ apple_feed.save! assert_equal default_feed, apple_feed.apple_config.public_feed end + + it "can return a list of possible apple shows" do + body = {data: [{id: "1", attributes: {title: "t1"}}, {id: "2", attributes: {title: "t2"}}], links: {}}.to_json + stub_request(:get, "https://api.podcastsconnect.apple.com/v1/shows").to_return(status: 200, body: body) + assert_equal apple_feed.apple_show_options, [["1 (t1)", "1"], ["2 (t2)", "2"]] + end end describe "#publish_to_apple?" do diff --git a/test/policies/feed_policy_test.rb b/test/policies/feed_policy_test.rb index 26614297d..4ba4f16fb 100644 --- a/test/policies/feed_policy_test.rb +++ b/test/policies/feed_policy_test.rb @@ -19,6 +19,11 @@ it "returns true if token is a member of the account" do assert FeedPolicy.new(member_token, feed).update? end + + it "returns false if the feed is edit_locked" do + feed.edit_locked = true + refute FeedPolicy.new(member_token, feed).update? + end end describe "#destroy?" do