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 %> + +<% 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 %> + +<% 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 %> + +<% 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" %> +
+
+
+
+
+
+
+ Episode Downloads + Last 28 Days +
+
+ <%= turbo_frame_tag "episodes", src: episodes_podcast_metrics_path(podcast_id: podcast.id), loading: "lazy" do %> + <%= render "metrics/loading_card" %> + <% end %> +
+
+
+
+
+
+
+
+ Monthly Downloads + Past Year +
+
+ <%= 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 %> + +<% 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") %>
<% end %> + <% end %>
+ <% if Rails.env.development? %> +
+ <% if uses_multiple_feeds(@podcast) %> +
+
+
+ Downloads by Feed + Last 28 Days +
+
+ <%= 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) %> +
+
+
+ Downloads by Season + All-Time +
+
+ <%= turbo_frame_tag "seasons", src: seasons_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy" do %> + <%= render "metrics/loading_card" %> + <% end %> +
+
+
+ <% end %> +
+
+
+ Downloads by Country + Last 28 Days +
+
+ <%= turbo_frame_tag "countries", src: countries_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy" do %> + <%= render "metrics/loading_card" %> + <% end %> +
+
+
+
+
+
+ Downloads by App + Last 28 Days +
+
+ <%= turbo_frame_tag "agent_apps", src: agents_podcast_metrics_path(podcast_id: @podcast.id), loading: "lazy" do %> + <%= render "metrics/loading_card" %> + <% end %> +
+
+
+
+ <% else %>
@@ -170,4 +236,5 @@
+ <% 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