-
Notifications
You must be signed in to change notification settings - Fork 1
Stream recording UI #1387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Stream recording UI #1387
Changes from 19 commits
0a25a17
93a3277
a5321be
b2e224f
9a87c29
18879b0
a3ad57f
796393e
618dae7
2f4dfa8
a928a18
22da9c2
8baae7c
f50627f
c5c5da9
f0f6ba7
c019a4c
db86033
a912ccd
6f2abcd
a5176ca
7dfe7d8
f37c6bd
f33b90c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| app/assets/builds | ||
| build | ||
| coverage | ||
| tmp | ||
|
|
||
| # Ignore vendor'd libraries | ||
| vendor | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| class PodcastStreamController < ApplicationController | ||
| before_action :set_stream | ||
|
|
||
| def show | ||
| end | ||
|
|
||
| def update | ||
| @stream.assign_attributes(stream_params) | ||
|
|
||
| respond_to do |format| | ||
| if @stream.save | ||
| format.html { redirect_to podcast_stream_path(@podcast), notice: t(".notice") } | ||
| else | ||
| flash.now[:error] = t(".error") | ||
| format.html { render :show, status: :unprocessable_entity } | ||
| end | ||
| end | ||
| rescue ActiveRecord::StaleObjectError | ||
| render :show, status: :conflict | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_stream | ||
| @podcast = Podcast.find(params[:podcast_id]) | ||
| @stream = @podcast.stream_recording || @podcast.build_stream_recording | ||
| @stream.clear_attribute_changes(%i[podcast_id]) | ||
| @stream.locking_enabled = true | ||
| authorize @stream | ||
| rescue ActiveRecord::RecordNotFound => e | ||
| render_not_found(e) | ||
| end | ||
|
|
||
| def stream_params | ||
| nilify params.fetch(:stream_recording, {}).permit( | ||
| :lock_version, | ||
| :url, | ||
| :status, | ||
| :start_date, | ||
| :end_date, | ||
| :create_as, | ||
| :expiration, | ||
| record_days: [], | ||
| record_hours: [] | ||
| ) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| module StreamRecordingsHelper | ||
| def stream_status_options | ||
| StreamRecording.statuses.keys.map { |k| [I18n.t("helpers.label.stream_recording.statuses.#{k}"), k] } | ||
| end | ||
|
|
||
| def stream_create_as_options | ||
| StreamRecording.create_as.keys.map { |k| [I18n.t("helpers.label.stream_recording.create_as_opts.#{k}"), k] } | ||
| end | ||
|
|
||
| def stream_expiration_options | ||
| I18n.t("helpers.label.stream_recording.expirations").invert.to_a | ||
| end | ||
|
|
||
| def stream_record_days_options(val) | ||
| label = I18n.t("helpers.label.stream_recording.record_all_days") | ||
| all = [label, "", {selected: val.blank?, data: {mandatory: true}}] | ||
| opts = StreamRecording::ALL_DAYS.map { |d| [I18n.t("date.day_names")[d % 7], d] } | ||
| options_for_select(opts.prepend(all), val) | ||
| end | ||
|
|
||
| def stream_record_hours_options(val) | ||
| label = I18n.t("helpers.label.stream_recording.record_all_hours") | ||
| all = [label, "", {selected: val.blank?, data: {mandatory: true}}] | ||
| opts = StreamRecording::ALL_HOURS.map { |h| [Time.new(2000, 1, 1, h, 0, 0).strftime("%l %p").strip, h] } | ||
| options_for_select(opts.prepend(all), val) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,49 +2,18 @@ import { Controller } from "@hotwired/stimulus" | |
| import SlimSelect from "slim-select" | ||
|
|
||
| export default class extends Controller { | ||
| static values = { groupSelect: Boolean, exclusive: Array } | ||
| static values = { exclusive: Array } | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Upgraded to slim select V3 to fix some pre-existing bugs with the "exclusive" value. (Only used on the episode planner, when you select "every other week" vs "week 1" etc). Had to make a few changes here to get everything working again. |
||
|
|
||
| connect() { | ||
| this.select = new SlimSelect({ | ||
| select: this.element, | ||
| settings: { | ||
| // TODO: broken | ||
| // selectByGroup: this.groupSelectValue, | ||
| placeholderText: "", | ||
| allowDeselect: this.hasEmpty(this.element), | ||
| showSearch: this.showSearch(this.element), | ||
| }, | ||
| events: { | ||
| afterChange: (val) => { | ||
| if (val.length > 0) { | ||
| this.select.selectEl.classList.remove("form-control-blank") | ||
| } else { | ||
| this.select.selectEl.classList.add("form-control-blank") | ||
| } | ||
| this.element.dispatchEvent(new Event("blur")) | ||
| }, | ||
| beforeChange: (newOpts, oldOpts) => { | ||
| if (this.exclusiveValue.length) { | ||
| const newVals = newOpts.map((o) => o.value) | ||
| const oldVals = oldOpts.map((o) => o.value) | ||
| const added = newVals.find((v) => !oldVals.includes(v)) | ||
| const addedExclusive = this.exclusiveValue.includes(added) ? added : null | ||
| const addedNonExclusive = !this.exclusiveValue.includes(added) ? added : null | ||
|
|
||
| // deselect when adding an exclusive | ||
| if (addedExclusive && oldVals.length) { | ||
| this.select.setSelected([addedExclusive]) | ||
| return false | ||
| } | ||
|
|
||
| // deselect if exclusive is selected and we added non-exclusive | ||
| if (oldVals.find((v) => this.exclusiveValue.includes(v)) && addedNonExclusive) { | ||
| this.select.setSelected([addedNonExclusive]) | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| }, | ||
| beforeChange: this.beforeChange.bind(this), | ||
| }, | ||
| }) | ||
| } | ||
|
|
@@ -66,4 +35,45 @@ export default class extends Controller { | |
| showSearch(element) { | ||
| return element.children.length > 10 | ||
| } | ||
|
|
||
| beforeChange(newVal, oldVal) { | ||
| if (this.exclusiveValue.length) { | ||
| const newVals = newVal.map((o) => o.value) | ||
| const oldVals = oldVal.map((o) => o.value) | ||
| const added = newVals.find((v) => !oldVals.includes(v)) | ||
| const addedExclusive = this.exclusiveValue.includes(added) ? added : null | ||
| const addedNonExclusive = !this.exclusiveValue.includes(added) ? added : null | ||
|
|
||
| // deselect when adding an exclusive | ||
| if (addedExclusive !== null && oldVals.length) { | ||
| this.selectLater([addedExclusive]) | ||
| } | ||
|
|
||
| // deselect if exclusive is selected and we added non-exclusive | ||
| if (oldVals.some((v) => this.exclusiveValue.includes(v)) && addedNonExclusive !== null) { | ||
| this.selectLater([addedNonExclusive]) | ||
| } | ||
| } | ||
|
|
||
| // fix material-style floating labels as selection changes | ||
| this.blankLater(newVal) | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| selectLater(val) { | ||
| setTimeout(() => { | ||
| this.select.setSelected([val]) | ||
| }, 1) | ||
| } | ||
|
|
||
| blankLater(val) { | ||
| setTimeout(() => { | ||
| if (val.length > 0) { | ||
| this.select.selectEl.classList.remove("form-control-blank") | ||
| } else { | ||
| this.select.selectEl.classList.add("form-control-blank") | ||
| } | ||
| }, 1) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| class StreamRecordingConfigJob < ApplicationJob | ||
| S3_KEY = "streams.json" | ||
|
|
||
| queue_as :feeder_default | ||
|
|
||
| def perform | ||
| s3_client.put_object( | ||
| body: StreamRecording.config.to_json, | ||
| bucket: s3_bucket, | ||
| cache_control: "max-age=60", | ||
| content_type: "application/json", | ||
| key: S3_KEY | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hopefully okay to just make this file public? Nothing secret in there. |
||
| ) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| class StreamRecording < ApplicationRecord | ||
| ALL_DAYS = (1..7).to_a | ||
| ALL_HOURS = (0..23).to_a | ||
|
|
||
| enum :status, %w[enabled disabled paused].to_enum_h, prefix: true | ||
| enum :create_as, %w[clips episodes].to_enum_h, prefix: true | ||
|
|
||
| serialize :record_days, coder: JSON | ||
| serialize :record_hours, coder: JSON | ||
|
|
||
| belongs_to :podcast, -> { with_deleted }, touch: true, optional: true | ||
|
|
||
| scope :active, ->(now = Time.now) { status_enabled.where("end_date IS NULL OR end_date > ?", now) } | ||
| scope :recording, ->(now = Time.now) { active.where("start_date > ?", now) } | ||
|
|
||
| validates :url, presence: true, http_url: true, http_head: /audio\/.+/ | ||
| validates :start_date, presence: true, if: :status_enabled? | ||
| validates :end_date, comparison: {greater_than: :start_date}, allow_nil: true, if: :start_date | ||
| validates :record_days, inclusion: {in: ALL_DAYS}, allow_nil: true | ||
| validates :record_hours, inclusion: {in: ALL_HOURS}, allow_nil: true | ||
| validates :expiration, numericality: {greater_than: 0}, allow_nil: true | ||
|
|
||
| after_initialize :set_defaults | ||
| after_save :write_config | ||
|
|
||
| acts_as_paranoid | ||
|
|
||
| def self.config | ||
| active.map do |s| | ||
| { | ||
| id: s.id, | ||
| gid: s.to_global_id.to_s, | ||
| podcast_id: s.podcast_id, | ||
| url: s.url, | ||
| start_date: s.start_date, | ||
| end_date: s.end_date, | ||
| record_days: s.record_days, | ||
| record_hours: s.record_hours, | ||
| callback: PorterUtils.callback_sqs | ||
| } | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Open to feedback on what this config is. |
||
| end | ||
| end | ||
|
|
||
| def set_defaults | ||
| set_default(:status, "disabled") | ||
| set_default(:create_as, "clips") | ||
| end | ||
|
|
||
| def write_config | ||
| StreamRecordingConfigJob.perform_later | ||
| end | ||
|
|
||
| def record_days=(val) | ||
| days = Array(val).reject(&:blank?).map(&:to_i).uniq.sort | ||
| if days.empty? || days == ALL_DAYS | ||
| super(nil) | ||
| else | ||
| super(days) | ||
| end | ||
| end | ||
|
|
||
| def record_hours=(val) | ||
| hours = Array(val).reject(&:blank?).map(&:to_i).uniq.sort | ||
| if hours.empty? || hours == ALL_HOURS | ||
| super(nil) | ||
| else | ||
| super(hours) | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| class HttpHeadValidator < ActiveModel::EachValidator | ||
| include HttpUtil | ||
|
|
||
| def self.skip_validation? | ||
| Rails.env.test? | ||
| end | ||
|
|
||
| def validate_each(rec, attr, val) | ||
| return if self.class.skip_validation? | ||
|
|
||
| if val.present? && HttpUrlValidator.http_url?(val) && (rec.new_record? || rec.changes[attr].present?) | ||
| res = memoize_http_head(val) | ||
|
|
||
| if res.nil? || !res.is_a?(Net::HTTPSuccess) | ||
| rec.errors.add(attr, :unreachable, message: "not http reachable") | ||
| elsif options[:with] && !has_content_type?(res, options[:with]) | ||
| rec.errors.add(attr, :invalid_content_type, message: "invalid content type") | ||
| end | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def memoize_http_head(url) | ||
| if @last_http_head == url | ||
| @last_http_res | ||
| else | ||
| @last_http_head = url | ||
| @last_http_res = http_head(url) | ||
| end | ||
| end | ||
|
|
||
| def has_content_type?(res, matching) | ||
| type = res.content_type.to_s.downcase | ||
|
|
||
| if options[:with].is_a?(String) && options[:with] == type | ||
| true | ||
| elsif options[:with].is_a?(Array) && options[:with].include?(type) | ||
| true | ||
| elsif options[:with].is_a?(Regexp) && options[:with].match?(type) | ||
| true | ||
| else | ||
| false | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| class StreamRecordingPolicy < ApplicationPolicy | ||
| def show? | ||
| PodcastPolicy.new(token, resource.podcast).show? | ||
| end | ||
|
|
||
| def create? | ||
| update? | ||
| end | ||
|
|
||
| def update? | ||
| PodcastPolicy.new(token, resource.podcast).update? | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Working around some slim-select-y limitations here. Made the
Every Dayoption an empty string, and mark it as mandatory, so you can't remove it.But the "exclusive" code in the stimulus controller will remove it if you select Wednesday or something.