diff --git a/Gemfile.lock b/Gemfile.lock
index a19bb74f1..405641ad3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -577,6 +577,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
+ arm64-darwin-25
x86_64-darwin-21
x86_64-darwin-22
x86_64-darwin-23
diff --git a/app/assets/stylesheets/app/metrics-card.scss b/app/assets/stylesheets/app/metrics-card.scss
index c9ead44d0..a5b90500c 100644
--- a/app/assets/stylesheets/app/metrics-card.scss
+++ b/app/assets/stylesheets/app/metrics-card.scss
@@ -1,11 +1,11 @@
.metrics-card-body {
- height: 450px;
+ height: 275px;
.metrics-chart {
height: 100%;
}
- .metrics-table {
- height: 100%;
- }
+ // .metrics-table {
+ // height: 100%;
+ // }
}
diff --git a/app/controllers/concerns/metrics_queries.rb b/app/controllers/concerns/metrics_queries.rb
new file mode 100644
index 000000000..2d2da8464
--- /dev/null
+++ b/app/controllers/concerns/metrics_queries.rb
@@ -0,0 +1,47 @@
+require "active_support/concern"
+
+module MetricsQueries
+ extend ActiveSupport::Concern
+
+ def alltime_downloads(model, select_override = nil)
+ model_id, column = model_attrs(model)
+ selection = select_override || column
+
+ Rollups::HourlyDownload
+ .where("#{column}": model_id)
+ .select(selection, "SUM(count) AS count")
+ .group(selection)
+ .final
+ .load_async
+ end
+
+ def daterange_downloads(model, date_start = Date.utc_today - 28.days, date_end = Time.now, interval = "DAY")
+ model_id, column = model_attrs(model)
+
+ Rollups::HourlyDownload
+ .where("#{column}": model_id, hour: (date_start..date_end))
+ .select(column, "DATE_TRUNC('#{interval}', hour) AS hour", "SUM(count) AS count")
+ .group(column, "DATE_TRUNC('#{interval}', hour) AS hour")
+ .order(Arel.sql("DATE_TRUNC('#{interval}', hour) ASC"))
+ .final
+ .load_async
+ end
+
+ def model_attrs(model)
+ model_id = if model.is_a?(Enumerable)
+ model.pluck(:guid)
+ elsif model.is_a?(Podcast)
+ model[:id]
+ elsif model.is_a?(Episode)
+ model[:guid]
+ end
+
+ column = if model.is_a?(Enumerable)
+ "#{model.first.class.to_s.downcase}_id"
+ else
+ "#{model.class.to_s.downcase}_id"
+ end
+
+ [model_id, column]
+ end
+end
diff --git a/app/controllers/concerns/metrics_utils.rb b/app/controllers/concerns/metrics_utils.rb
index 6564e626d..8000b8e15 100644
--- a/app/controllers/concerns/metrics_utils.rb
+++ b/app/controllers/concerns/metrics_utils.rb
@@ -55,25 +55,39 @@ def primary_blue
"#0072a3"
end
+ def light_pink
+ "#e7d4ff"
+ end
+
+ def light_blue
+ "#aafff5"
+ end
+
+ def orange
+ "#ff9601"
+ end
+
def single_rollups(downloads, label = I18n.t(".helpers.label.metrics.chart.all_episodes"))
{
rollups: downloads,
- color: primary_blue,
+ color: light_blue,
label: label
}
end
- def multiple_episode_rollups(episodes, rollups, totals)
- episodes.to_enum(:each_with_index).map do |episode, i|
+ def multiple_episode_rollups(episodes, rollups)
+ episodes.map.with_index do |episode, i|
+ color = if i == 0
+ orange
+ else
+ light_blue
+ end
{
episode: episode,
rollups: rollups.select do |r|
r["episode_id"] == episode.guid
end,
- totals: totals.select do |r|
- r["episode_id"] == episode.guid
- end,
- color: colors[i]
+ color: color
}
end
end
diff --git a/app/controllers/podcast_metrics_controller.rb b/app/controllers/podcast_metrics_controller.rb
index cce288a7d..7a85746fc 100644
--- a/app/controllers/podcast_metrics_controller.rb
+++ b/app/controllers/podcast_metrics_controller.rb
@@ -1,5 +1,6 @@
class PodcastMetricsController < ApplicationController
include MetricsUtils
+ include MetricsQueries
before_action :set_podcast
# before_action :check_clickhouse, except: %i[show]
@@ -29,60 +30,87 @@ def episode_sparkline
}
end
- def downloads
- @downloads_within_date_range =
- Rollups::HourlyDownload
- .where(podcast_id: @podcast.id, hour: (@date_start..@date_end))
- .select("DATE_TRUNC('#{@interval}', hour) AS hour", "SUM(count) AS count")
- .group("DATE_TRUNC('#{@interval}', hour) AS hour")
- .order(Arel.sql("DATE_TRUNC('#{@interval}', hour) ASC"))
- .load_async
+ def monthly_downloads
+ @date_start = (Date.utc_today - 11.months).beginning_of_month
+ @date_end = Date.utc_today
+ @date_range = generate_date_range(@date_start, @date_end.beginning_of_month, "MONTH")
+ @downloads_within_date_range = daterange_downloads(@podcast, @date_start, @date_end, "MONTH")
- @downloads = single_rollups(@downloads_within_date_range)
+ @downloads = single_rollups(@downloads_within_date_range, "Downloads")
- render partial: "metrics/downloads_card", locals: {
- url: request.fullpath,
- form_id: "podcast_downloads_metrics",
- date_start: @date_start,
- date_end: @date_end,
- interval: @interval,
+ render partial: "metrics/monthly_card", locals: {
date_range: @date_range,
downloads: @downloads
}
end
def episodes
- @episodes =
- @podcast.episodes
- .published
- .order(first_rss_published_at: :desc)
- .paginate(params[:episodes], params[:per])
+ @episodes = @podcast.episodes.published.dropdate_desc.limit(10)
+ @date_range = generate_date_range(Date.utc_today - 28.days, Date.utc_today, "DAY")
- @episodes_recent =
- Rollups::HourlyDownload
- .where(podcast_id: @podcast.id, episode_id: @episodes.pluck(:guid), hour: (@date_start..@date_end))
- .select(:episode_id, "DATE_TRUNC('#{@interval}', hour) AS hour", "SUM(count) AS count")
- .group(:episode_id, "DATE_TRUNC('#{@interval}', hour) AS hour")
- .order(Arel.sql("DATE_TRUNC('#{@interval}', hour) ASC"))
- .load_async
- @episodes_alltime =
+ @episodes_downloads = daterange_downloads(@episodes)
+
+ @episode_rollups = multiple_episode_rollups(@episodes, @episodes_downloads)
+
+ render partial: "metrics/episodes_card", locals: {
+ episode_rollups: @episode_rollups,
+ date_range: @date_range
+ }
+ end
+
+ def feeds
+ feed_slugs = @podcast.feeds.pluck(:slug).map { |slug| slug.nil? ? "" : slug }
+ date_start = (Date.utc_today - 28.days)
+
+ @downloads_by_feed =
Rollups::HourlyDownload
- .where(podcast_id: @podcast.id, episode_id: @episodes.pluck(:guid))
- .select(:episode_id, "SUM(count) AS count")
- .group(:episode_id)
+ .where(podcast_id: @podcast.id, feed_slug: feed_slugs, hour: (date_start..))
+ .select(:feed_slug, "SUM(count) AS count")
+ .group(:feed_slug)
+ .order(Arel.sql("SUM(count) AS count DESC"))
+ .final
.load_async
- @episode_rollups = multiple_episode_rollups(@episodes, @episodes_recent, @episodes_alltime)
+ feeds_with_downloads = []
- render partial: "metrics/episodes_card", locals: {
- url: request.fullpath,
- form_id: "podcast_episodes_metrics",
- date_start: @date_start,
- date_end: @date_end,
- interval: @interval,
- date_range: @date_range,
- episodes: @episodes,
- episode_rollups: @episode_rollups
+ @feeds = @downloads_by_feed.map do |rollup|
+ feed = if rollup[:feed_slug].blank?
+ @podcast.default_feed
+ else
+ @podcast.feeds.where(slug: rollup[:feed_slug]).first
+ end
+
+ feeds_with_downloads << feed
+
+ {
+ feed: feed,
+ downloads: rollup
+ }
+ end
+
+ @podcast.feeds.each { |feed| @feeds << {feed: feed} if feeds_with_downloads.exclude?(feed) }
+
+ render partial: "metrics/feeds_card", locals: {
+ podcast: @podcast,
+ feeds: @feeds
+ }
+ end
+
+ def seasons
+ published_seasons = @podcast.episodes.published.pluck(:season_number).uniq
+
+ @season_rollups = published_seasons.map do |season|
+ episodes = @podcast.episodes.published.where(season_number: season)
+ rollup = alltime_downloads(episodes, "podcast_id")
+
+ {
+ season_number: season,
+ downloads: rollup.first
+ }
+ end
+
+ render partial: "metrics/seasons_card", locals: {
+ seasons: @season_rollups
}
end
@@ -154,55 +182,69 @@ def dropdays
}
end
- def geos
- # @top_subdivs =
- # Rollups::DailyGeo
- # .where(podcast_id: @podcast.id)
- # .select(:country_code, :subdiv_code, "DATE_TRUNC('WEEK', day) AS day", "SUM(count) AS count")
- # .group(:country_code, :subdiv_code, "DATE_TRUNC('WEEK', day) AS day")
- # .order(Arel.sql("SUM(count) AS count DESC"))
- # .limit(10)
- # @top_countries =
- # Rollups::DailyGeo
- # .where(podcast_id: @podcast.id)
- # .select(:country_code, "SUM(count) AS count")
- # .group(:country_code)
- # .order(Arel.sql("SUM(count) AS count DESC"))
- # .limit(10)
+ def countries
+ date_start = (Date.utc_today - 28.days).to_s
+ date_end = Date.utc_today.to_s
+
+ top_countries =
+ Rollups::DailyGeo
+ .where(podcast_id: @podcast.id, day: date_start..date_end)
+ .select(:country_code, "SUM(count) AS count")
+ .group(:country_code)
+ .order(Arel.sql("SUM(count) AS count DESC"))
+ .final
+ .limit(10)
+ .load_async
+
+ top_country_codes = top_countries.pluck(:country_code)
+
+ other_countries =
+ Rollups::DailyGeo
+ .where(podcast_id: @podcast.id, day: date_start..date_end)
+ .where.not(country_code: top_country_codes)
+ .select("'Other' AS country_code", "SUM(count) AS count")
+ .final
+ .load_async
+
+ @country_rollups = []
+ @country_rollups << top_countries
+ @country_rollups << other_countries
+
+ render partial: "metrics/countries_card", locals: {
+ countries: @country_rollups.flatten
+ }
end
def agents
- # @agent_apps_query =
- # Rollups::DailyAgent
- # .where(podcast_id: @podcast.id)
- # .select("agent_name_id AS code", "SUM(count) AS count")
- # .group("agent_name_id AS code")
- # .order(Arel.sql("SUM(count) AS count DESC"))
- # .load_async
- # @agent_types_query =
- # Rollups::DailyAgent
- # .where(podcast_id: @podcast.id)
- # .select("agent_type_id AS code", "SUM(count) AS count")
- # .group("agent_type_id AS code")
- # .order(Arel.sql("SUM(count) AS count DESC"))
- # .load_async
- # @agent_os_query =
- # Rollups::DailyAgent
- # .where(podcast_id: @podcast.id)
- # .select("agent_os_id AS code", "SUM(count) AS count")
- # .group("agent_os_id AS code")
- # .order(Arel.sql("SUM(count) AS count DESC"))
- # .load_async
-
- # @agent_apps = Kaminari.paginate_array(@agent_apps_query).page(params[:agent_apps]).per(10)
- # @agent_types = Kaminari.paginate_array(@agent_types_query).page(params[:agent_types]).per(10)
- # @agent_os = Kaminari.paginate_array(@agent_os_query).page(params[:agent_os]).per(10)
-
- # render partial: "agents", locals: {
- # agent_apps: @agent_apps,
- # agent_types: @agent_types,
- # agent_os: @agent_os
- # }
+ date_start = (Date.utc_today - 28.days).to_s
+ date_end = Date.utc_today.to_s
+
+ agent_apps =
+ Rollups::DailyAgent
+ .where(podcast_id: @podcast.id, day: date_start..date_end)
+ .select("agent_name_id AS code", "SUM(count) AS count")
+ .group("agent_name_id AS code")
+ .order(Arel.sql("SUM(count) AS count DESC"))
+ .final
+ .limit(10)
+ .load_async
+
+ top_apps_ids = agent_apps.pluck(:code)
+ other_apps =
+ Rollups::DailyAgent
+ .where(podcast_id: @podcast.id, day: date_start..date_end)
+ .where.not(agent_name_id: top_apps_ids)
+ .select("'Other' AS code", "SUM(count) AS count")
+ .final
+ .load_async
+
+ @agent_rollups = []
+ @agent_rollups << agent_apps
+ @agent_rollups << other_apps
+
+ render partial: "metrics/agent_apps_card", locals: {
+ agents: @agent_rollups.flatten
+ }
end
private
diff --git a/app/controllers/podcasts_controller.rb b/app/controllers/podcasts_controller.rb
index 030b0c073..51cf9de26 100644
--- a/app/controllers/podcasts_controller.rb
+++ b/app/controllers/podcasts_controller.rb
@@ -1,6 +1,7 @@
class PodcastsController < ApplicationController
include Prx::Api
include SlackHelper
+ include MetricsQueries
before_action :set_podcast, only: %i[show edit update destroy]
@@ -19,7 +20,12 @@ def show
@recently_published_episodes = @podcast.episodes.published.dropdate_desc.limit(4)
@trend_episodes = @podcast.default_feed.episodes.published.dropdate_desc.where.not(first_rss_published_at: nil).offset(1).limit(4)
- @episode_trend_pairs = episode_trend_pairs(@recently_published_episodes, @trend_episodes)
+ if Rails.env.development?
+ @episode_trend_pairs = episode_trend_pairs(@recently_published_episodes, @trend_episodes)
+ @alltime_downloads = alltime_downloads(@podcast).sum(&:count)
+ @daterange_downloads = daterange_downloads(@podcast).sum(&:count)
+ end
+ @episode_count = @podcast.episodes.published.length
# @recently_published is used for the prod branch
@recently_published = @recently_published_episodes[0..2]
diff --git a/app/helpers/metrics_helper.rb b/app/helpers/metrics_helper.rb
index 6fb22a364..158271bb2 100644
--- a/app/helpers/metrics_helper.rb
+++ b/app/helpers/metrics_helper.rb
@@ -116,4 +116,16 @@ def dropday_range_options
def uniques_selection_options
Rollups::DailyUnique::UNIQUE_OPTIONS.map { |opt| [I18n.t(".helpers.label.metrics.uniques.#{opt}"), opt] }
end
+
+ def uses_multiple_feeds(model)
+ model.feeds.length > 1
+ end
+
+ def uses_seasons(model)
+ if model.is_a?(Podcast)
+ model.episodes.published.where.not(season_number: nil).length > 0
+ elsif model.is_a?(Episode)
+ model.season_number.present?
+ end
+ end
end
diff --git a/app/javascript/controllers/apex_episodes_controller.js b/app/javascript/controllers/apex_episodes_controller.js
index eed4d59c9..42f09b3da 100644
--- a/app/javascript/controllers/apex_episodes_controller.js
+++ b/app/javascript/controllers/apex_episodes_controller.js
@@ -1,22 +1,25 @@
import { Controller } from "@hotwired/stimulus"
-import { buildDateTimeChart, buildDownloadsSeries, LINE_TYPE } from "util/apex"
+import { buildDateTimeChart, buildDownloadsSeries, LINE_TYPE, destroyChart } from "util/apex"
export default class extends Controller {
static values = {
id: String,
- selectedEpisodes: Array,
+ rollups: Array,
dateRange: Array,
- interval: String,
options: String,
}
static targets = ["chart"]
connect() {
- const series = buildDownloadsSeries(this.selectedEpisodesValue, this.dateRangeValue)
- const title = `Downloads by ${this.intervalValue.toLowerCase()}`
- const chart = buildDateTimeChart(this.idValue, series, this.chartTarget, LINE_TYPE, title)
+ const series = buildDownloadsSeries(this.rollupsValue, this.dateRangeValue)
+
+ const chart = buildDateTimeChart(this.idValue, series, this.chartTarget, LINE_TYPE, this.dateRangeValue)
chart.render()
}
+
+ disconnect() {
+ destroyChart(this.idValue)
+ }
}
diff --git a/app/javascript/controllers/apex_monthly_controller.js b/app/javascript/controllers/apex_monthly_controller.js
new file mode 100644
index 000000000..d3c4e1a8e
--- /dev/null
+++ b/app/javascript/controllers/apex_monthly_controller.js
@@ -0,0 +1,24 @@
+import { Controller } from "@hotwired/stimulus"
+import { buildDateTimeChart, buildDownloadsSeries, BAR_TYPE, destroyChart } from "util/apex"
+
+export default class extends Controller {
+ static values = {
+ id: String,
+ downloads: Object,
+ dateRange: Array,
+ }
+
+ static targets = ["chart"]
+
+ connect() {
+ const series = buildDownloadsSeries(this.downloadsValue, this.dateRangeValue)
+
+ const chart = buildDateTimeChart(this.idValue, series, this.chartTarget, BAR_TYPE)
+
+ chart.render()
+ }
+
+ disconnect() {
+ destroyChart(this.idValue)
+ }
+}
diff --git a/app/javascript/controllers/apex_sparkbar_controller.js b/app/javascript/controllers/apex_sparkbar_controller.js
new file mode 100644
index 000000000..74113b083
--- /dev/null
+++ b/app/javascript/controllers/apex_sparkbar_controller.js
@@ -0,0 +1,35 @@
+import { Controller } from "@hotwired/stimulus"
+import { buildSparkbarChart, destroyChart } from "util/apex"
+
+export default class extends Controller {
+ static values = {
+ id: String,
+ downloads: Array,
+ }
+
+ static targets = ["chart"]
+
+ connect() {
+ const seriesData = this.downloadsValue.map((rollup) => {
+ if (rollup) {
+ return rollup.count
+ } else {
+ return null
+ }
+ })
+ const series = [
+ {
+ name: "Downloads",
+ data: seriesData,
+ },
+ ]
+
+ const chart = buildSparkbarChart(this.idValue, series, this.chartTarget)
+
+ chart.render()
+ }
+
+ disconnect() {
+ destroyChart(this.idValue)
+ }
+}
diff --git a/app/javascript/util/apex.js b/app/javascript/util/apex.js
index 5e8ac0acf..6912fc858 100644
--- a/app/javascript/util/apex.js
+++ b/app/javascript/util/apex.js
@@ -3,7 +3,7 @@ import ApexCharts from "apexcharts"
const DEFAULT_OPTIONS = {
chart: {
width: "100%",
- height: "400px",
+ height: "100%",
zoom: { enabled: false },
animations: {
enabled: false,
@@ -34,12 +34,10 @@ const DATETIME_OPTIONS = {
},
tooltip: {
enabled: true,
- shared: true,
- hideEmptySeries: false,
- intersect: false,
- x: {
- format: "MMM d, h:mmtt",
- },
+ shared: false,
+ hideEmptySeries: true,
+ intersect: true,
+ // followCursor: true,
},
dataLabels: {
enabled: false,
@@ -63,15 +61,38 @@ const NUMERIC_OPTIONS = {
},
}
+const lightBlue = "#aafff5"
+const lightPink = "#e7d4ff"
+
export const BAR_TYPE = {
chart: {
type: "bar",
stacked: false,
+ animations: {
+ enabled: false,
+ },
+ sparkline: {
+ enabled: false,
+ },
},
options: {
fill: {
- type: "solid",
- opacity: 0.8,
+ colors: [lightBlue],
+ type: "gradient",
+ opacity: 1,
+ gradient: {
+ type: "vertical",
+ shade: "light",
+ inverseColors: false,
+ gradientToColors: [lightPink],
+ },
+ },
+ states: {
+ hover: {
+ filter: {
+ type: "none",
+ },
+ },
},
},
}
@@ -80,11 +101,20 @@ export const LINE_TYPE = {
chart: {
type: "line",
stacked: false,
+ animations: {
+ enabled: false,
+ },
+ sparkline: {
+ enabled: false,
+ },
},
options: {
stroke: {
curve: "smooth",
- width: 2,
+ width: 3,
+ },
+ markers: {
+ showNullDataPoints: false,
},
},
}
@@ -125,13 +155,13 @@ export const SPARKLINE_TYPE = {
enabled: false,
},
fill: {
- colors: ["#F3EAFF"],
+ colors: [lightBlue],
type: "gradient",
gradient: {
- type: "diagonal1",
- gradientToColors: ["#DBFBFB"],
- opacityFrom: 1,
- opacityTo: 1,
+ type: "vertical",
+ shade: "light",
+ gradientToColors: [lightPink],
+ inverseColors: false,
},
},
stroke: {
@@ -141,15 +171,73 @@ export const SPARKLINE_TYPE = {
},
}
+export const SPARKBAR_TYPE = {
+ chart: {
+ height: "100%",
+ sparkline: {
+ enabled: true,
+ },
+ animations: {
+ enabled: true,
+ },
+ type: "bar",
+ stacked: false,
+ },
+ options: {
+ xaxis: {
+ type: "category",
+ },
+ tooltip: {
+ enabled: false,
+ },
+ fill: {
+ colors: [lightBlue],
+ type: "gradient",
+ gradient: {
+ type: "horizontal",
+ shade: "light",
+ gradientToColors: [lightPink],
+ inverseColors: true,
+ opacityFrom: 0.9,
+ opacityTo: 0.9,
+ },
+ },
+ stroke: {
+ width: 1,
+ colors: ["#00000000"],
+ },
+ plotOptions: {
+ bar: {
+ horizontal: true,
+ barHeight: "90%",
+ },
+ },
+ states: {
+ hover: {
+ filter: {
+ type: "none",
+ },
+ },
+ },
+ },
+}
+
export function buildSparklineChart(id, series, target) {
const options = Object.assign({ series: series }, DEFAULT_OPTIONS, SPARKLINE_TYPE.options)
Object.assign(options.chart, { id: id }, SPARKLINE_TYPE.chart)
return new ApexCharts(target, options)
}
-export function buildDateTimeChart(id, series, target, type, title = "") {
+export function buildSparkbarChart(id, series, target) {
+ const options = Object.assign({ series: series }, DEFAULT_OPTIONS, SPARKBAR_TYPE.options)
+ Object.assign(options.chart, { id: id }, SPARKBAR_TYPE.chart)
+ return new ApexCharts(target, options)
+}
+
+export function buildDateTimeChart(id, series, target, type, dateRange = [], title = "") {
const options = Object.assign({ series: series }, DEFAULT_OPTIONS, DATETIME_OPTIONS, type.options)
Object.assign(options.chart, { id: id }, type.chart)
+ Object.assign(options.xaxis, { categories: dateRange })
addYaxisTitle(options.yaxis, title)
return new ApexCharts(target, options)
}
@@ -173,11 +261,16 @@ export function buildNumericChart(id, series, target, type, title = "") {
export function buildDownloadsSeries(data, dateRange) {
if (Array.isArray(data)) {
- return data.map((episodeRollup) => {
+ return data.map((episodeRollup, i) => {
+ let zIndex = 1
+ if (i === 0) {
+ zIndex = 2
+ }
return {
name: episodeRollup.episode.title,
data: alignDownloadsOnDateRange(episodeRollup.rollups, dateRange),
color: episodeRollup.color,
+ zIndex: zIndex,
}
})
} else {
@@ -205,7 +298,7 @@ function alignDownloadsOnDateRange(downloads, range) {
} else {
return {
x: date,
- y: 0,
+ y: null,
}
}
})
diff --git a/app/models/rollups/daily_agent.rb b/app/models/rollups/daily_agent.rb
index cf49e8ff8..ea61d6fc0 100644
--- a/app/models/rollups/daily_agent.rb
+++ b/app/models/rollups/daily_agent.rb
@@ -1,154 +1,39 @@
class Rollups::DailyAgent < ActiveRecord::Base
establish_connection :clickhouse
- AGENT_TAGS = {
- 0 => "Unknown",
- 1 => "HermesPod",
- 2 => "Acast",
- 3 => "Alexa",
- 4 => "AllYouCanBooks",
- 5 => "AntennaPod",
- 6 => "Breaker",
- 7 => "Castaway",
- 8 => "CastBox",
- 9 => "Castro",
- 10 => "Clementine",
- 11 => "Downcast",
- 12 => "iTunes",
- 13 => "NPR One",
- 14 => "Overcast",
- 15 => "Player FM",
- 16 => "Pocket Casts",
- 17 => "Podbean",
- 18 => "PodcastAddict",
- 19 => "The Podcast App",
- 20 => "Podkicker",
- 21 => "RadioPublic",
- 22 => "Sonos",
- 23 => "Stitcher",
- 24 => "Zune",
- 25 => "Apple Podcasts",
- 26 => "Internet Explorer",
- 27 => "Safari",
- 28 => "Firefox",
- 29 => "Chrome",
- 30 => "Facebook",
- 31 => "Twitter",
- 32 => "Apple News",
- 33 => "BeyondPod",
- 34 => "NetCast",
- 35 => "Desktop App",
- 36 => "Mobile App",
- 37 => "Smart Home",
- 38 => "Smart TV",
- 39 => "Desktop Browser",
- 40 => "Mobile Browser",
- 41 => "Windows",
- 42 => "Android",
- 43 => "iOS",
- 44 => "Amazon OS",
- 45 => "macOS",
- 46 => "BlackBerryOS",
- 47 => "Windows Phone",
- 48 => "ChromeOS",
- 49 => "Linux",
- 50 => "webOS",
- 51 => "gPodder",
- 52 => "iHeartRadio",
- 53 => "Juice Receiver",
- 54 => "Laughable",
- 55 => "Windows Media Player",
- 56 => "PodCruncher",
- 57 => "PodTrapper",
- 58 => "PodcastRepublic",
- 59 => "TED",
- 60 => "TuneIn",
- 61 => "Winamp",
- 62 => "Google Podcasts",
- 63 => "RSSRadio",
- 64 => "Roku",
- 65 => "ServeStream",
- 66 => "uTorrent",
- 67 => "Google Home",
- 68 => "Smart Watch",
- 69 => "WatchOS",
- 70 => "Himalaya",
- 71 => "MediaMonkey",
- 72 => "iCatcher",
- 73 => "KPCC App",
- 74 => "Sonos OS",
- 75 => "Podbbang",
- 76 => "HardCast",
- 77 => "Spotify",
- 78 => "AhaRadio",
- 79 => "Bullhorn",
- 80 => "CloudPlayer",
- 81 => "English Radio IELTS TOEFL",
- 82 => "Pandora",
- 83 => "Procast",
- 84 => "Treble.fm",
- 85 => "WNYC App",
- 86 => "Bose",
- 87 => "myTuner",
- 88 => "sodes",
- 89 => "WBEZ App",
- 90 => "Wilson FM",
- 91 => "Luminary",
- 92 => "Edge",
- 93 => "DoggCatcher",
- 94 => "Chromecast",
- 95 => "Squeezebox",
- 96 => "Spreaker",
- 97 => "VictorReader",
- 98 => "Podcoin",
- 99 => "Castamatic",
- 100 => "Deezer",
- 101 => "Audiobooks",
- 102 => "Hamro Patro",
- 103 => "HondaLink",
- 104 => "Hubhopper",
- 105 => "Instacast",
- 106 => "KERA App",
- 107 => "Kids Listen",
- 108 => "Kodi",
- 109 => "MusicBee",
- 110 => "Orange Radio",
- 111 => "Outcast",
- 112 => "Playapod",
- 113 => "Plex",
- 114 => "PRI App",
- 115 => "WBUR App",
- 116 => "Opera",
- 117 => "This American Life",
- 118 => "Podimo",
- 119 => "BashPodder",
- 120 => "Outlook",
- 121 => "Amazon Fire TV",
- 122 => "Podcast Guru",
- 123 => "Xiaoyuzhou",
- 124 => "Nvidia Shield",
- 125 => "Sony Bravia",
- 126 => "Amazon Music",
- 127 => "TikTok",
- 128 => "SiriusXM",
- 129 => "iVoox",
- 130 => "Audible",
- 131 => "Airr",
- 132 => "Podhero",
- 133 => "MixerBox",
- 134 => "Xbox",
- 135 => "Samsung Free",
- 136 => "Snipd",
- 137 => "Telmate",
- 138 => "castget",
- 139 => "Newsboat",
- 140 => "Anghami",
- 141 => "VLC",
- 142 => "PRX Play",
- 143 => "mowPod"
- }.freeze
+ LABELS =
+ begin
+ agents_lock_pathname = Rails.root.join("vendor/agents.lock.yml")
+ if File.exist?(agents_lock_pathname)
+ db = YAML.load_file(agents_lock_pathname)
+ agent_codes = db["agents"]
+ .map { |v| [v["os"], v["name"], v["type"]] }
+ .flatten
+ .compact
+ .uniq
+ agent_label_lookup = agent_codes.map { |code| [db["tags"][code], code] }.to_h
+ agent_label_lookup.invert.freeze
+ else
+ message = "Missing the vendor/agents.lock.yml file from prx-podagents\n"
+ message += "Agent labels will be mangled until you download it!!"
+ Rails.logger.error(msg: message)
+ {}
+ end
+ end
- def label
- AGENT_TAGS[code]
+ def self.label_for(code)
+ LABELS[code.to_s] || unknown_code(code)
+ end
+
+ def self.code_for(label)
+ LABELS.key(label) || label
+ end
+
+ def self.unknown_code(code)
+ if code.blank? || code == 0
+ "Unknown"
+ else
+ "Other"
+ end
end
end
diff --git a/app/models/rollups/daily_geo.rb b/app/models/rollups/daily_geo.rb
index a701261b2..6da74427e 100644
--- a/app/models/rollups/daily_geo.rb
+++ b/app/models/rollups/daily_geo.rb
@@ -1,3 +1,15 @@
class Rollups::DailyGeo < ActiveRecord::Base
establish_connection :clickhouse
+
+ def country
+ ISO3166::Country[country_code] || "Other"
+ end
+
+ def country_label
+ if country_code == "Other"
+ "Other"
+ else
+ country.iso_short_name
+ end
+ end
end
diff --git a/app/views/metrics/_agent_apps_card.html.erb b/app/views/metrics/_agent_apps_card.html.erb
new file mode 100644
index 000000000..6bc024498
--- /dev/null
+++ b/app/views/metrics/_agent_apps_card.html.erb
@@ -0,0 +1,16 @@
+<%= turbo_frame_tag "agent_apps" do %>
+
+ <% agents.map do |agent| %>
+ -
+ <%= Rollups::DailyAgent.label_for(agent[:code]) %>
+ <%= number_with_delimiter(agent[:count]) || 0 %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/metrics/_countries_card.html.erb b/app/views/metrics/_countries_card.html.erb
new file mode 100644
index 000000000..b6d517c05
--- /dev/null
+++ b/app/views/metrics/_countries_card.html.erb
@@ -0,0 +1,16 @@
+<%= turbo_frame_tag "countries" do %>
+
+ <% countries.map do |country| %>
+ -
+ <%= country.country_label %>
+ <%= number_with_delimiter(country[:count]) || 0 %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/metrics/_downloads_card.html.erb b/app/views/metrics/_downloads_card.html.erb
deleted file mode 100644
index a10e25c50..000000000
--- a/app/views/metrics/_downloads_card.html.erb
+++ /dev/null
@@ -1,17 +0,0 @@
-<%= turbo_frame_tag "downloads" do %>
- <%= form_with(url: url, method: :get, id: form_id, data: {controller: "click", click_debounce_value: 200}) do |form| %>
-
-
- <%= form.date_field :date_start, class: "d-none", data: {apex_nav_target: "startDate", action: "change->click#submit"}, value: date_start %>
- <%= form.date_field :date_end, class: "d-none", data: {apex_nav_target: "endDate", action: "change->click#submit"}, value: date_end %>
- <%= form.select :interval, interval_options, {selected: interval}, class: "d-none", data: {apex_nav_target: "interval", action: "change->click#submit", path: "downloads"} %>
- <%= form.submit class: "d-none", data: {click_target: "submit"} %>
- <% end %>
-<% end %>
diff --git a/app/views/metrics/_episode_card.html.erb b/app/views/metrics/_episode_card.html.erb
new file mode 100644
index 000000000..67ae1ec35
--- /dev/null
+++ b/app/views/metrics/_episode_card.html.erb
@@ -0,0 +1,8 @@
+
+
<%= local_date(episode.published_at, format: :short) %>
+
+
<%= link_to episode.title, episode_path(episode) %>
+
+ <%# turbo_frame_tag "episode_sparkline", src: episode_sparkline_podcast_metrics_path(podcast_id: podcast.id, episode_id: episode.guid, prev_episode_id: prev_episode.guid), loading: "lazy" do %>
+ <%# end %>
+
diff --git a/app/views/metrics/_episodes_card.html.erb b/app/views/metrics/_episodes_card.html.erb
index 790142215..fc3e58a75 100644
--- a/app/views/metrics/_episodes_card.html.erb
+++ b/app/views/metrics/_episodes_card.html.erb
@@ -1,8 +1,9 @@
-
-
<%= local_date(episode.published_at, format: :short) %>
-
-
<%= link_to episode.title, episode_path(episode) %>
+<%= turbo_frame_tag "episodes" do %>
+
- <%= turbo_frame_tag "episode_sparkline", src: episode_sparkline_podcast_metrics_path(podcast_id: podcast.id, episode_id: episode.guid, prev_episode_id: prev_episode.guid), loading: "lazy" do %>
- <% end %>
-
+<% end %>
diff --git a/app/views/metrics/_feeds_card.html.erb b/app/views/metrics/_feeds_card.html.erb
new file mode 100644
index 000000000..7f38bc878
--- /dev/null
+++ b/app/views/metrics/_feeds_card.html.erb
@@ -0,0 +1,16 @@
+<%= turbo_frame_tag "feeds" do %>
+
+ <% feeds.map do |feed| %>
+ -
+ <%= link_to feed[:feed].label, podcast_feed_path(podcast, feed[:feed]) %>
+ <%= number_with_delimiter(feed[:downloads]&.count) || 0 %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/metrics/_monthly_card.html.erb b/app/views/metrics/_monthly_card.html.erb
new file mode 100644
index 000000000..8dedd2e6a
--- /dev/null
+++ b/app/views/metrics/_monthly_card.html.erb
@@ -0,0 +1,9 @@
+<%= turbo_frame_tag "monthly" do %>
+
+<% end %>
diff --git a/app/views/metrics/_score_card.html.erb b/app/views/metrics/_score_card.html.erb
new file mode 100644
index 000000000..4f15d6314
--- /dev/null
+++ b/app/views/metrics/_score_card.html.erb
@@ -0,0 +1,9 @@
+
+
+
+
<%= score %>
+ <%= label %>
+ <%= sublabel %>
+
+
+
diff --git a/app/views/metrics/_scorecard_dashboard.html.erb b/app/views/metrics/_scorecard_dashboard.html.erb
new file mode 100644
index 000000000..ab99dca1d
--- /dev/null
+++ b/app/views/metrics/_scorecard_dashboard.html.erb
@@ -0,0 +1,45 @@
+
+
+
+ <%= render "metrics/score_card", score: number_with_delimiter(daterange_downloads), label: "downloads", sublabel: "last 28 days" %>
+
+
+ <%= render "metrics/score_card", score: number_with_delimiter(alltime_downloads), label: "downloads", sublabel: "all-time" %>
+
+
+ <%= render "metrics/score_card", score: number_with_delimiter(episode_count), label: "episodes", sublabel: "all-time" %>
+
+
+
+
+
+
+
+
+ <%= turbo_frame_tag "episodes", src: episodes_podcast_metrics_path(podcast_id: podcast.id), loading: "lazy" do %>
+ <%= render "metrics/loading_card" %>
+ <% end %>
+
+
+
+
+
+
+
+
+
+ <%= turbo_frame_tag "monthly", src: monthly_downloads_podcast_metrics_path(podcast_id: podcast.id), loading: "lazy" do %>
+ <%= render "metrics/loading_card" %>
+ <% end %>
+
+
+
+
+
+
diff --git a/app/views/metrics/_seasons_card.html.erb b/app/views/metrics/_seasons_card.html.erb
new file mode 100644
index 000000000..28b82b075
--- /dev/null
+++ b/app/views/metrics/_seasons_card.html.erb
@@ -0,0 +1,16 @@
+<%= turbo_frame_tag "seasons" do %>
+
+ <% seasons.map do |season| %>
+ -
+ <%= "Season #{season[:season_number]}" %>
+ <%= number_with_delimiter(season[:downloads]&.count) || 0 %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/podcasts/show.html.erb b/app/views/podcasts/show.html.erb
index 7ff996645..8831494ba 100644
--- a/app/views/podcasts/show.html.erb
+++ b/app/views/podcasts/show.html.erb
@@ -44,7 +44,7 @@
<% if Rails.env.development? %>
<% if @episode_trend_pairs.present? %>
<% @episode_trend_pairs.each do |pair| %>
- <%= render "metrics/episodes_card", podcast: @podcast, episode: pair[:episode], prev_episode: pair[:prev_episode] %>
+ <%= render "metrics/episode_card", podcast: @podcast, episode: pair[:episode], prev_episode: pair[:prev_episode] %>
<% end %>
<% else %>
<%= t(".no_published") %>
@@ -91,6 +91,11 @@
+ <% if Rails.env.development? %>
+
+ <%= render "metrics/scorecard_dashboard", daterange_downloads: @daterange_downloads, alltime_downloads: @alltime_downloads, episode_count: @episode_count, podcast: @podcast %>
+
+ <% else %>
<% if current_user_app?("metrics") %>
+ <% if Rails.env.development? %>
+
+ <% if uses_multiple_feeds(@podcast) %>
+
+
+
+
+ <%= turbo_frame_tag "feeds", src: feeds_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy", target: "_top" do %>
+ <%= render "metrics/loading_card" %>
+ <% end %>
+
+
+
+ <% end %>
+ <% if uses_seasons(@podcast) %>
+
+
+
+
+ <%= turbo_frame_tag "seasons", src: seasons_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy" do %>
+ <%= render "metrics/loading_card" %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
+ <%= turbo_frame_tag "countries", src: countries_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy" do %>
+ <%= render "metrics/loading_card" %>
+ <% end %>
+
+
+
+
+
+
+
+ <%= turbo_frame_tag "agent_apps", src: agents_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy" do %>
+ <%= render "metrics/loading_card" %>
+ <% end %>
+
+
+
+
+ <% else %>
+ <% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 657e7315d..be728ecc3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -23,7 +23,10 @@
get "rollups_demo", to: "podcasts#rollups_demo"
resource :metrics, only: [:show], controller: :podcast_metrics do
get "episode_sparkline"
- get "downloads"
+ get "monthly_downloads"
+ get "feeds"
+ get "seasons"
+ get "countries"
get "uniques"
get "episodes"
get "dropdays"
diff --git a/vendor/agents.lock.yml b/vendor/agents.lock.yml
new file mode 100644
index 000000000..ca0ec01ac
--- /dev/null
+++ b/vendor/agents.lock.yml
@@ -0,0 +1,1044 @@
+agents:
+ - regex: ^\+hermespod\.com
+ name: "1"
+ type: "35"
+ os: "41"
+ - regex: "^Acast.+[aA]ndroid"
+ name: "2"
+ type: "36"
+ os: "42"
+ - regex: ^Acast.+(CFNetwork|iOS)
+ name: "2"
+ type: "36"
+ os: "43"
+ - regex: ^AhaRadio.+CFNetwork
+ name: "78"
+ type: "36"
+ os: "43"
+ - regex: ^Airr.+CFNetwork
+ name: "131"
+ type: "36"
+ os: "43"
+ - regex: ^(AlexaMediaPlayer|AlexaService)
+ name: "3"
+ type: "37"
+ os: "44"
+ - regex: ^AllYouCanBooks.+CFNetwork
+ name: "4"
+ type: "36"
+ os: "43"
+ - regex: ^Amazon;AFT
+ name: "121"
+ type: "38"
+ os: "44"
+ - regex: ^AmazonMusic.+CFNetwork
+ name: "126"
+ type: "36"
+ os: "43"
+ - regex: ^AmazonMusic.+Android
+ name: "126"
+ type: "36"
+ os: "42"
+ - regex: ^Amazon ?Music
+ name: "126"
+ type: "36"
+ - regex: ^AndroidDownloadManager
+ type: "36"
+ os: "42"
+ - regex: ^Anghami.+Android
+ name: "140"
+ type: "36"
+ os: "42"
+ - regex: ^Anghami.+CFNetwork
+ name: "140"
+ type: "36"
+ os: "43"
+ - regex: ^AntennaPod|^de\.danoeh\.antennapod
+ name: "5"
+ type: "36"
+ os: "42"
+ - regex: ^Apache-HttpClient
+ type: "36"
+ os: "42"
+ - regex: ^APKXDL.+Android
+ type: "36"
+ os: "42"
+ - regex: ^(Audible|com\.audible).+Android
+ name: "130"
+ type: "36"
+ os: "42"
+ - regex: ^Audible.+CFNetwork
+ name: "130"
+ type: "36"
+ os: "43"
+ - regex: ^IOSAudiobooks.+CFNetwork
+ name: "101"
+ type: "36"
+ os: "43"
+ - regex: ^BashPodder
+ name: "119"
+ type: "35"
+ os: "49"
+ - regex: ^Mozilla\/5\.0.+BeyondPod
+ name: "33"
+ type: "36"
+ os: "42"
+ - regex: ^Bose\/
+ name: "86"
+ type: "37"
+ - regex: ^Breaker\/iOS
+ name: "6"
+ type: "36"
+ os: "43"
+ - regex: ^Breaker
+ name: "6"
+ type: "36"
+ os: "42"
+ - regex: ^Bullhorn
+ name: "79"
+ type: "36"
+ - regex: ^Castamatic.+(CFNetwork|iOS)
+ name: "99"
+ type: "36"
+ os: "43"
+ - regex: ^Castaway.+CFNetwork
+ name: "7"
+ type: "36"
+ os: "43"
+ - regex: "^Cast[Bb]ox.+Android"
+ name: "8"
+ type: "36"
+ os: "42"
+ - regex: "^Cast[Bb]ox.+iOS"
+ name: "8"
+ type: "36"
+ os: "43"
+ - regex: ^CastBox\/com\.castbox\.CastBox
+ name: "8"
+ type: "36"
+ os: "43"
+ - regex: ^CastBox
+ name: "8"
+ type: "36"
+ - regex: ^castget
+ name: "138"
+ type: "35"
+ os: "49"
+ - regex: ^Castro
+ name: "9"
+ type: "36"
+ os: "43"
+ - regex: ^(Mozilla\/.+CrKey|Google;Chromecast)
+ name: "94"
+ type: "37"
+ os: "48"
+ - regex: ^Clementine
+ name: "10"
+ type: "35"
+ - regex: ^Dalvik.+Android
+ type: "36"
+ os: "42"
+ - regex: ^Deezer.+windows
+ name: "100"
+ type: "35"
+ os: "41"
+ - regex: ^Deezer.+osx
+ name: "100"
+ type: "35"
+ os: "45"
+ - regex: ^Deezer.+Android
+ name: "100"
+ type: "36"
+ os: "42"
+ - regex: ^Deezer.+CFNetwork
+ name: "100"
+ type: "36"
+ os: "43"
+ - regex: ^(Mozilla.+)?DoggCatcher
+ name: "93"
+ type: "36"
+ os: "42"
+ - regex: ^doubleTwist CloudPlayer
+ name: "80"
+ type: "36"
+ os: "42"
+ - regex: ^Downcast.+(iPhone|iPad|iPod)
+ name: "11"
+ type: "36"
+ os: "43"
+ - regex: ^Downcast.+OS X
+ name: "11"
+ type: "35"
+ os: "45"
+ - regex: ^iTunes.+Downcast
+ name: "11"
+ - regex: ^Echo
+ name: "3"
+ type: "37"
+ os: "44"
+ - regex: ^English.+Radio.+IELTS.+TOEFL.+CFNetwork
+ name: "81"
+ type: "36"
+ os: "43"
+ - regex: ^(ExoMediaPlayer.+Android|ExoPlayer.+Android|yourApplicationName.+Android.+ExoPlayer|Exo2.+Android)
+ type: "36"
+ os: "42"
+ - regex: ^FileDownloader\/
+ type: "36"
+ os: "42"
+ - regex: ^Fire OS.+stagefright
+ type: "36"
+ os: "42"
+ - regex: ^gPodder.+Windows
+ name: "51"
+ type: "35"
+ os: "41"
+ - regex: ^gPodder.+Linux
+ name: "51"
+ type: "35"
+ os: "49"
+ - regex: ^gPodder
+ name: "51"
+ type: "35"
+ - regex: ^gvfs
+ type: "35"
+ os: "49"
+ - regex: ^Hamro-Patro.+Android
+ name: "102"
+ type: "36"
+ os: "42"
+ - regex: ^Hamro-Patro
+ name: "102"
+ type: "36"
+ os: "43"
+ - regex: ^HardCast.+CFNetwork
+ name: "76"
+ type: "36"
+ os: "43"
+ - regex: ^Himalaya.+CFNetwork
+ name: "70"
+ type: "36"
+ os: "43"
+ - regex: ^Himalaya.+Android
+ name: "70"
+ type: "36"
+ os: "42"
+ - regex: ^HondaLink.+CFNetwork
+ name: "103"
+ type: "36"
+ os: "43"
+ - regex: ^Hubhopper.+CFNetwork
+ name: "104"
+ type: "36"
+ os: "43"
+ - regex: ^Hubhopper.+Android
+ name: "104"
+ type: "36"
+ os: "42"
+ - regex: ^(iCatcher!|iTunes.+iCatcher!)
+ name: "72"
+ type: "36"
+ os: "43"
+ - regex: ^iHeartRadio.+(iPad|iPhone|iPod|CFNetwork)
+ name: "52"
+ type: "36"
+ os: "43"
+ - regex: ^iHeartRadio.+Android
+ name: "52"
+ type: "36"
+ os: "42"
+ - regex: ^Instacast.+CFNetwork
+ name: "105"
+ type: "36"
+ os: "43"
+ - regex: ^iTunes.+Macintosh
+ name: "12"
+ type: "35"
+ os: "45"
+ - regex: ^iTunes.+Windows
+ name: "12"
+ type: "35"
+ os: "41"
+ - regex: ^iTunes.+iPod
+ name: "12"
+ type: "36"
+ os: "43"
+ - regex: "^i[vV]oox.+Android"
+ name: "129"
+ type: "36"
+ os: "42"
+ - regex: "^i[vV]oox.+(iPhone|iPad|iOS|CFNetwork)"
+ name: "129"
+ type: "36"
+ os: "43"
+ - regex: "^i[vV]oox"
+ name: "129"
+ type: "36"
+ - regex: ^Juice.+Windows
+ name: "53"
+ type: "35"
+ os: "41"
+ - regex: ^KERA.+IOS
+ name: "106"
+ type: "36"
+ os: "43"
+ - regex: ^Kids Listen.+CFNetwork
+ name: "107"
+ type: "36"
+ os: "43"
+ - regex: ^Kodi.+Windows
+ name: "108"
+ type: "37"
+ os: "41"
+ - regex: ^Kodi.+Android
+ name: "108"
+ type: "37"
+ os: "42"
+ - regex: ^Kodi.+Linux
+ name: "108"
+ type: "37"
+ os: "49"
+ - regex: ^KPCCAndroid
+ name: "73"
+ type: "36"
+ os: "42"
+ - regex: ^KPCC.+CFNetwork
+ name: "73"
+ type: "36"
+ os: "43"
+ - regex: ^Lavf
+ type: "35"
+ - regex: ^Laughable.+iOS
+ name: "54"
+ type: "36"
+ os: "43"
+ - regex: ^(LG-|LM-|Player).+LG Player.+Android
+ type: "36"
+ os: "42"
+ - regex: ^Luminary.+(iOS|CFNetwork)
+ name: "91"
+ type: "36"
+ os: "43"
+ - regex: ^Luminary.+Android
+ name: "91"
+ type: "36"
+ os: "42"
+ - regex: ^MediaMonkey
+ name: "71"
+ type: "35"
+ os: "41"
+ - regex: ^mowpod\.com
+ name: "143"
+ - regex: ^myTuner.+CFNetwork
+ name: "87"
+ type: "36"
+ os: "43"
+ - regex: ^(mytuner.+Android)|^MyTuner-ExoPlayer
+ name: "87"
+ type: "36"
+ os: "42"
+ - regex: ^myTuner
+ name: "87"
+ type: "36"
+ - regex: ^MusicBee
+ name: "109"
+ type: "35"
+ os: "41"
+ - regex: ^Newsboat.+Linux
+ name: "139"
+ type: "35"
+ os: "49"
+ - regex: ^NPR One.+CFNetwork
+ name: "13"
+ type: "36"
+ os: "43"
+ - regex: ^NPROneAndroid
+ name: "13"
+ type: "36"
+ os: "42"
+ - regex: ^(NSPlayer|WMFSDK|WMPlayer)
+ name: "55"
+ type: "35"
+ os: "41"
+ - regex: ^NVIDIA;SHIELDAndroidTV
+ name: "124"
+ type: "38"
+ os: "42"
+ - regex: ^OkDownload
+ type: "36"
+ os: "42"
+ - regex: ^okhttp
+ type: "36"
+ os: "42"
+ - regex: ^OrangeRadio.+CFNetwork
+ name: "110"
+ type: "36"
+ os: "43"
+ - regex: ^Outcast
+ name: "111"
+ type: "68"
+ os: "69"
+ - regex: ^Mozilla.+MSOffice 16
+ name: "120"
+ - regex: ^Overcast.+Apple Watch
+ name: "14"
+ type: "68"
+ os: "69"
+ - regex: ^Overcast
+ name: "14"
+ type: "36"
+ os: "43"
+ - regex: ^Pandora.+Android
+ name: "82"
+ type: "36"
+ os: "42"
+ - regex: ^Mozilla.+(iPad|iPhone).+Pandora
+ name: "82"
+ type: "36"
+ os: "43"
+ - regex: ^Pandora.+CFNetwork
+ name: "82"
+ type: "36"
+ os: "43"
+ - regex: ^Mozilla.+Windows.+PandoraApp
+ name: "82"
+ type: "35"
+ os: "41"
+ - regex: ^Mozilla.+Macintosh.+PandoraApp
+ name: "82"
+ type: "35"
+ os: "45"
+ - regex: ^Playapod.+CFNetwork
+ name: "112"
+ type: "36"
+ os: "43"
+ - regex: ^Playapod.+Android
+ name: "112"
+ type: "36"
+ os: "42"
+ - regex: ^Player FM.+CFNetwork
+ name: "15"
+ type: "36"
+ os: "43"
+ - regex: ^Player FM
+ name: "15"
+ type: "36"
+ os: "42"
+ - regex: ^Plex.+iOS
+ name: "113"
+ type: "36"
+ os: "43"
+ - regex: ^Pocket Casts
+ name: "16"
+ type: "36"
+ - regex: ^Podbean.+Android
+ name: "17"
+ type: "36"
+ os: "42"
+ - regex: ^Podbean\/iOS
+ name: "17"
+ type: "36"
+ os: "43"
+ - regex: ^(PodCruncher.+CFNetwork|iTunes.+PodCruncher)
+ name: "56"
+ type: "36"
+ os: "43"
+ - regex: ^Podbbang.+Android
+ name: "75"
+ type: "36"
+ os: "42"
+ - regex: "^[Pp]odbbang.+(iOS|CFNetwork)"
+ name: "75"
+ type: "36"
+ os: "42"
+ - regex: ^Podbbang
+ name: "75"
+ type: "36"
+ - regex: ^PodTrapper
+ name: "57"
+ type: "36"
+ os: "42"
+ - regex: ^(PodcastAddict|Podcast Addict).+Android
+ name: "18"
+ type: "36"
+ os: "42"
+ - regex: '^(podcast\/[0-9]+ (iOS|CFNetwork))|^(com\.evolve\.podcast\/.+iOS)|^(ThePodcastApp\/.+(iOS|iPadOS))'
+ name: "19"
+ type: "36"
+ os: "43"
+ - regex: ^PodcastGuru
+ name: "122"
+ type: "36"
+ os: "42"
+ - regex: ^PodcastRepublic.+Android
+ name: "58"
+ type: "36"
+ os: "42"
+ - regex: ^Podcoin
+ name: "98"
+ type: "36"
+ - regex: ^Podimo.+Android
+ name: "118"
+ type: "36"
+ os: "42"
+ - regex: ^Podimo.+iOS
+ name: "118"
+ type: "36"
+ os: "43"
+ - regex: ^Podhero.+Android
+ name: "132"
+ type: "36"
+ os: "42"
+ - regex: ^Podhero.+CFNetwork
+ name: "132"
+ type: "36"
+ os: "43"
+ - regex: ^Podhero
+ name: "132"
+ type: "36"
+ - regex: ^Podkicker
+ name: "20"
+ type: "36"
+ os: "42"
+ - regex: ^PRI\/.+CFNetwork
+ name: "114"
+ type: "36"
+ os: "43"
+ - regex: "^Pro[Cc]ast.+(iOS|CFNetwork)"
+ name: "83"
+ type: "36"
+ os: "43"
+ - regex: ^play\.prx\.org
+ name: "142"
+ - regex: ^RadioPublic Android|^RadioPublic\/android
+ name: "21"
+ type: "36"
+ os: "42"
+ - regex: ^RadioPublic iOS|^RadioPublic.+CFNetwork|^RadioPublic/iOS
+ name: "21"
+ type: "36"
+ os: "43"
+ - regex: ^ReactNativeVideo.+Android
+ type: "36"
+ os: "42"
+ - regex: ^(Roku|roku)
+ name: "64"
+ type: "37"
+ - regex: ^RSSRadio
+ name: "63"
+ type: "36"
+ os: "43"
+ - regex: ^samsung-agent\/
+ type: "37"
+ os: "42"
+ - regex: ^Samsung Free\/|^sp-agent
+ name: "135"
+ type: "36"
+ os: "42"
+ - regex: ^ServeStream
+ name: "65"
+ type: "36"
+ os: "42"
+ - regex: ^SiriusXM.+CFNetwork
+ name: "128"
+ type: "36"
+ os: "43"
+ - regex: ^Snipd.+Android
+ name: "136"
+ type: "36"
+ os: "42"
+ - regex: ^Snipd.+CFNetwork
+ name: "136"
+ type: "36"
+ os: "43"
+ - regex: ^Sodes.+CFNetwork
+ name: "88"
+ type: "36"
+ os: "43"
+ - regex: ^(Sonos|Linux.+Sonos)
+ name: "22"
+ type: "37"
+ os: "74"
+ - regex: ^Sony;BRAVIA
+ name: "125"
+ type: "38"
+ os: "42"
+ - regex: ^Spotify(-Lite)?\/.+Android
+ name: "77"
+ type: "36"
+ os: "42"
+ - regex: ^Spotify\/.+iOS
+ name: "77"
+ type: "36"
+ os: "43"
+ - regex: ^Spotify\/.+OSX
+ name: "77"
+ type: "35"
+ os: "45"
+ - regex: ^Spotify\/.+Win32
+ name: "77"
+ type: "35"
+ os: "41"
+ - regex: ^Spotify\/.+Linux
+ name: "77"
+ type: "35"
+ os: "49"
+ - regex: ^Spotify\/.+(Volvo|Rivian|Polestar)
+ name: "77"
+ type: "36"
+ os: "42"
+ - regex: ^Spreaker for Android
+ name: "96"
+ type: "36"
+ os: "42"
+ - regex: ^Spreaker.+(iPhone|iPad|iOS)
+ name: "96"
+ type: "36"
+ os: "43"
+ - regex: ^Stitcher.+Android
+ name: "23"
+ type: "36"
+ os: "42"
+ - regex: ^iTunes.+(SqueezeCenter|SqueezeNetwork)
+ name: "95"
+ type: "37"
+ os: "49"
+ - regex: ^Stitcher\/iOS
+ name: "23"
+ type: "36"
+ os: "43"
+ - regex: ^TED.+CFNetwork
+ name: "59"
+ type: "36"
+ os: "43"
+ - regex: ^(TED.+Android)|(com\.sciker\.tedtalksdaily)
+ name: "59"
+ type: "36"
+ os: "42"
+ - regex: ^telmate-audio-player.+Android
+ name: "137"
+ type: "36"
+ os: "42"
+ - regex: ^This Am Life.+Android
+ name: "117"
+ type: "36"
+ os: "42"
+ - regex: ^ThisAmericanLife.+CFNetwork
+ name: "117"
+ type: "36"
+ os: "43"
+ - regex: ^TREBLE
+ name: "84"
+ type: "36"
+ os: "43"
+ - regex: ^TuneIn.+CFNetwork
+ name: "60"
+ type: "36"
+ os: "43"
+ - regex: ^TuneIn.+Android
+ name: "60"
+ type: "36"
+ os: "42"
+ - regex: ^TuneIn Radio
+ name: "60"
+ - regex: ^(uTorrent|BTWebClient)
+ name: "66"
+ type: "35"
+ - regex: ^VictorReader
+ name: "97"
+ type: "37"
+ - regex: ^VLC\/
+ name: "141"
+ type: "35"
+ - regex: ^LibVLC.+Android
+ name: "141"
+ type: "36"
+ os: "42"
+ - regex: ^com\.wbez\.app.+Android
+ name: "89"
+ type: "36"
+ os: "42"
+ - regex: ^W(BU|UB)R.+Android
+ name: "115"
+ type: "36"
+ os: "42"
+ - regex: ^WNYC.+Android
+ name: "85"
+ type: "36"
+ os: "42"
+ - regex: ^WNYC.+(Darwin|iPhone|iOS)
+ name: "85"
+ type: "36"
+ os: "43"
+ - regex: ^Wilson\/
+ name: "90"
+ type: "36"
+ os: "43"
+ - regex: ^Winamp
+ name: "61"
+ type: "35"
+ os: "41"
+ - regex: ^Xiaoyuzhou
+ name: "123"
+ type: "36"
+ os: "43"
+ - regex: ^microsoft;xbox
+ name: "134"
+ type: "38"
+ - regex: ^Zune
+ name: "24"
+ type: "36"
+ os: "41"
+ - regex: ^MixerBox\/.*Android
+ name: "133"
+ type: "36"
+ os: "42"
+ - regex: ^MixerBox\/.*(iOS|CFNetwork)
+ name: "133"
+ type: "36"
+ os: "43"
+ - regex: ^(Podcasts|Podcast’ler|Podcast|Podcaster|Podcasti|Podcastit|Podcastok|Podcasturi|Podcasty|Podkaster|Balados|Подкасти|Подкасты|פודקאסטים|البودكاست|पॉडकास्ट|พ็อดคาสท์|播客|팟캐스트|ポッドキャスト)\/.+(x86_64)
+ name: "25"
+ type: "35"
+ os: "45"
+ - regex: ^(Podcasts|Podcast’ler|Podcast|Podcaster|Podcasti|Podcastit|Podcastok|Podcasturi|Podcasty|Podkaster|Balados|Подкасти|Подкасты|פודקאסטים|البودكاست|पॉडकास्ट|พ็อดคาสท์|播客|팟캐스트|ポッドキャスト)\/
+ name: "25"
+ type: "36"
+ os: "43"
+ - regex: ^itunesstored
+ name: "25"
+ type: "36"
+ os: "43"
+ - regex: ^(AppleCoreMedia.+Apple TV|apple;apple_tv)
+ name: "25"
+ type: "38"
+ os: "43"
+ - regex: ^AppleCoreMedia.+(Audio Accessory|HomePod)
+ name: "25"
+ type: "37"
+ os: "43"
+ - regex: ^AppleCoreMedia.+(iPhone|iPad|iPod)
+ name: "25"
+ type: "36"
+ os: "43"
+ - regex: ^AppleCoreMedia.+Apple Watch
+ name: "25"
+ type: "68"
+ os: "69"
+ - regex: ^(atc\/.+(watchOS)?|\(null\).+watchOS)
+ bot: true
+ name: "25"
+ type: "68"
+ os: "69"
+ - regex: ^AppleCoreMedia.+Macintosh
+ name: "12"
+ type: "35"
+ os: "45"
+ - regex: ^Mozilla\/5\.0.+Android.+GSA\/|^GSA\/|^com.google.android.googlequicksearchbox.+Android
+ name: "62"
+ type: "36"
+ os: "42"
+ - regex: ^(Mozilla\/5\.0.+iPhone.+GSA\/)|(GooglePodcasts.+(iPhone|iPad))
+ name: "62"
+ type: "36"
+ os: "43"
+ - regex: ^(Libassistant.+)?GoogleChirp\/
+ name: "62"
+ type: "37"
+ os: "67"
+ - regex: ^Mozilla\/4\.0.+MSIE
+ name: "26"
+ type: "39"
+ os: "41"
+ - regex: ^Mozilla\/5\.0.+BB10.+Safari
+ name: "27"
+ type: "40"
+ os: "46"
+ - regex: ^Mozilla\/5\.0.+Windows Phone.+Edge\/
+ name: "92"
+ type: "40"
+ os: "47"
+ - regex: ^Mozilla\/5\.0.+Windows Phone.+IEMobile
+ name: "26"
+ type: "40"
+ os: "47"
+ - regex: ^Mozilla\/5\.0.+Android.+musical_ly
+ name: "127"
+ type: "36"
+ os: "42"
+ - regex: ^Mozilla\/5\.0.+Android.+Firefox
+ name: "28"
+ type: "40"
+ os: "42"
+ - regex: ^Mozilla\/5\.0.+Android.+Chrome
+ name: "29"
+ type: "40"
+ os: "42"
+ - regex: ^Mozilla\/5\.0.+Android
+ type: "40"
+ os: "42"
+ - regex: ^Samsung.+stagefright
+ type: "40"
+ os: "42"
+ - regex: ^stagefright.+Android
+ type: "40"
+ os: "42"
+ - regex: ^Mozilla\/5\.0.+Macintosh.+Firefox
+ name: "28"
+ type: "39"
+ os: "45"
+ - regex: ^Mozilla\/5\.0.+Macintosh.+Chrome
+ name: "29"
+ type: "39"
+ os: "45"
+ - regex: ^Mozilla\/5\.0.+Macintosh.+Safari
+ name: "27"
+ type: "39"
+ os: "45"
+ - regex: ^Mozilla\/5\.0.+Macintosh
+ type: "39"
+ os: "45"
+ - regex: ^Mozilla\/5\.0.+Windows NT.+Edge
+ name: "92"
+ type: "39"
+ os: "41"
+ - regex: ^Mozilla\/5\.0.+Windows NT.+Firefox
+ name: "28"
+ type: "39"
+ os: "41"
+ - regex: ^Mozilla\/5\.0.+Windows NT.+Chrome
+ name: "29"
+ type: "39"
+ os: "41"
+ - regex: ^Mozilla\/5\.0.+Windows NT
+ name: "26"
+ type: "39"
+ os: "41"
+ - regex: ^Opera\/.+Windows
+ name: "116"
+ type: "39"
+ os: "41"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+FxiOS
+ name: "28"
+ type: "40"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+CriOS
+ name: "29"
+ type: "40"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+FBAN
+ name: "30"
+ type: "36"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+Twitter
+ name: "31"
+ type: "36"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+AppleNews
+ name: "32"
+ type: "36"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+musical_ly
+ name: "127"
+ type: "36"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad).+Safari
+ name: "27"
+ type: "40"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+(iPhone|iPad)
+ type: "40"
+ os: "43"
+ - regex: ^Mozilla\/5\.0.+CrOS
+ name: "29"
+ type: "39"
+ os: "48"
+ - regex: ^Mozilla\/5\.0.+Tizen.+TV
+ type: "38"
+ os: "49"
+ - regex: ^Mozilla\/5\.0.+Tizen
+ type: "40"
+ os: "49"
+ - regex: ^Mozilla\/5\.0.+NetCast
+ name: "34"
+ type: "38"
+ os: "50"
+ - regex: ^Mozilla\/5\.0.+Linux.+Firefox
+ name: "28"
+ type: "39"
+ os: "49"
+ - regex: ^Mozilla\/5\.0.+(OpenBSD|NetBSD|Linux).+Chrome
+ name: "29"
+ type: "39"
+ os: "49"
+ - regex: ^Mozilla\/5\.0.+Linux
+ type: "39"
+ os: "49"
+ - regex: ^Opera\/.+Linux
+ name: "116"
+ type: "39"
+ os: "49"
+ - regex: ^Mozilla\/5\.0.+Gecko.+Firefox
+ name: "28"
+ type: "39"
+ - regex: ^Chrome
+ name: "29"
+ type: "39"
+ - regex: ^Mozilla\/5\.0( compatible)?$
+ type: "39"
+ - regex: "^Linux;Android [0-9]"
+ os: "42"
+ - regex: ^Mozilla\/5\.0.+Cloudinary
+ bot: true
+ - regex: >-
+ ^fyyd-poll|^itms|^mozilla\/5.0.+google-podcast|^stitcherbot|^rest-client|castfeedvalidator|^amazonnewscontentservice|^trackable|luminary\/1\.0|spotify\/1\.0|^podtrac
+ network|^anchorimport|^adswizz-podscribe|^deezer podcasters|^riddler|^mozilla\/5.0.+ina dlweb
+ ignorecase: true
+ bot: true
+ - regex: bot|spider|crawl|slurp|scan|scrap|archiver|transcoder|^curl|wget|^ruby|^python|^java|perl|php|httpclient|http-client|wordpress|facebook|yahoo|^pinterest|HWCDN|appengine|hwcdn|httrack|feedstation
+ ignorecase: true
+ bot: true
+tags:
+ "1": HermesPod
+ "2": Acast
+ "3": Alexa
+ "4": AllYouCanBooks
+ "5": AntennaPod
+ "6": Breaker
+ "7": Castaway
+ "8": CastBox
+ "9": Castro
+ "10": Clementine
+ "11": Downcast
+ "12": iTunes
+ "13": NPR One
+ "14": Overcast
+ "15": Player FM
+ "16": Pocket Casts
+ "17": Podbean
+ "18": PodcastAddict
+ "19": The Podcast App
+ "20": Podkicker
+ "21": RadioPublic
+ "22": Sonos
+ "23": Stitcher
+ "24": Zune
+ "25": Apple Podcasts
+ "26": Internet Explorer
+ "27": Safari
+ "28": Firefox
+ "29": Chrome
+ "30": Facebook
+ "31": Twitter
+ "32": Apple News
+ "33": BeyondPod
+ "34": NetCast
+ "35": Desktop App
+ "36": Mobile App
+ "37": Smart Home
+ "38": Smart TV
+ "39": Desktop Browser
+ "40": Mobile Browser
+ "41": Windows
+ "42": Android
+ "43": iOS
+ "44": Amazon OS
+ "45": macOS
+ "46": BlackBerryOS
+ "47": Windows Phone
+ "48": ChromeOS
+ "49": Linux
+ "50": webOS
+ "51": gPodder
+ "52": iHeartRadio
+ "53": Juice Receiver
+ "54": Laughable
+ "55": Windows Media Player
+ "56": PodCruncher
+ "57": PodTrapper
+ "58": PodcastRepublic
+ "59": TED
+ "60": TuneIn
+ "61": Winamp
+ "62": Google Podcasts
+ "63": RSSRadio
+ "64": Roku
+ "65": ServeStream
+ "66": uTorrent
+ "67": Google Home
+ "68": Smart Watch
+ "69": WatchOS
+ "70": Himalaya
+ "71": MediaMonkey
+ "72": iCatcher
+ "73": KPCC App
+ "74": Sonos OS
+ "75": Podbbang
+ "76": HardCast
+ "77": Spotify
+ "78": AhaRadio
+ "79": Bullhorn
+ "80": CloudPlayer
+ "81": English Radio IELTS TOEFL
+ "82": Pandora
+ "83": Procast
+ "84": Treble.fm
+ "85": WNYC App
+ "86": Bose
+ "87": myTuner
+ "88": "'sodes"
+ "89": WBEZ App
+ "90": Wilson FM
+ "91": Luminary
+ "92": Edge
+ "93": DoggCatcher
+ "94": Chromecast
+ "95": Squeezebox
+ "96": Spreaker
+ "97": VictorReader
+ "98": Podcoin
+ "99": Castamatic
+ "100": Deezer
+ "101": Audiobooks
+ "102": Hamro Patro
+ "103": HondaLink
+ "104": Hubhopper
+ "105": Instacast
+ "106": KERA App
+ "107": Kids Listen
+ "108": Kodi
+ "109": MusicBee
+ "110": Orange Radio
+ "111": Outcast
+ "112": Playapod
+ "113": Plex
+ "114": PRI App
+ "115": WBUR App
+ "116": Opera
+ "117": This American Life
+ "118": Podimo
+ "119": BashPodder
+ "120": Outlook
+ "121": Amazon Fire TV
+ "122": Podcast Guru
+ "123": Xiaoyuzhou
+ "124": Nvidia Shield
+ "125": Sony Bravia
+ "126": Amazon Music
+ "127": TikTok
+ "128": SiriusXM
+ "129": iVoox
+ "130": Audible
+ "131": Airr
+ "132": Podhero
+ "133": MixerBox
+ "134": Xbox
+ "135": Samsung Free
+ "136": Snipd
+ "137": Telmate
+ "138": castget
+ "139": Newsboat
+ "140": Anghami
+ "141": VLC
+ "142": PRX Play
+ "143": mowPod