From 03e23a28bf9b1b73fba55352c36a8cd6cc8ae729 Mon Sep 17 00:00:00 2001
From: Benjamin Bellamy
Date: Tue, 6 Oct 2020 15:39:27 +0000
Subject: [PATCH] feat: update analytics so to meet IABv2 requirements
- https://iabtechlab.com/wp-content/uploads/2017/12/Podcast_Measurement_v2-Dec-20-2017.pdf
- no IP address is ever stored on the server. Only aggregate data is stored in the dababase.
- rolling 24-hour window
- castopod does not do pre-load
- IP Blacklisting https://github.com/client9/ipcat
- user-agent Filtering https://github.com/opawg/user-agents
- ignores 2 bytes range "Range: 0-1" (performed by official Apple iOS Podcast app)
- in case of partial content, adds up all requests to check >1mn was downloaded
- identifying Uniques is done with a combination of IP Address and User Agent
- add AMcharts
- add some graphs
- add regions to analytics
- add ipcat blacklist
- enhance useragents performances
- add filesize and header size in order to calculate 1mn downloads
- update publisher ID3 field
- update castopod icon
- add disclaimer and warning import form translation
- update docs/setup-development.md
closes #10
---
.gitlab-ci.yml | 8 +-
DEPENDENCIES.md | 8 +-
app/Config/Routes.php | 32 +-
app/Controllers/Admin/AnalyticsData.php | 69 +
app/Controllers/Admin/Podcast.php | 26 +-
app/Controllers/Analytics.php | 14 +-
app/Controllers/BaseController.php | 3 +-
.../2020-05-30-101500_add_podcasts.php | 7 +
.../2020-06-05-170000_add_episodes.php | 6 +
...20-06-08-120000_add_analytics_podcasts.php | 49 +
...000_add_analytics_podcasts_by_episode.php} | 34 +-
...0000_add_analytics_podcasts_by_player.php} | 37 +-
...000_add_analytics_podcasts_by_country.php} | 15 +-
...0000_add_analytics_podcasts_by_region.php} | 41 +-
...0000_add_analytics_website_by_browser.php} | 16 +-
...0000_add_analytics_website_by_referer.php} | 29 +-
...00_add_analytics_website_by_entry_page.php | 54 +
...10000_add_analytics_episodes_by_player.php | 71 -
...dd_analytics_podcasts_stored_procedure.php | 52 +-
...add_analytics_website_stored_procedure.php | 18 +-
.../Seeds/FakePodcastsAnalyticsSeeder.php | 176 ++
.../Seeds/FakeWebsiteAnalyticsSeeder.php | 260 +++
...iteByCountry.php => AnalyticsPodcasts.php} | 7 +-
...yer.php => AnalyticsPodcastsByEpisode.php} | 7 +-
app/Entities/AnalyticsPodcastsByPlayer.php | 5 +-
app/Entities/AnalyticsPodcastsByRegion.php | 26 +
...ry.php => AnalyticsWebsiteByEntryPage.php} | 9 +-
app/Entities/Episode.php | 16 +
app/Entities/Podcast.php | 1 +
app/Helpers/analytics_helper.php | 261 ++-
app/Helpers/id3_helper.php | 7 +-
app/Helpers/rss_helper.php | 10 +-
app/Language/en/Breadcrumb.php | 1 +
app/Language/en/Podcast.php | 2 +-
app/Language/en/PodcastImport.php | 6 +
app/Language/en/PodcastNavigation.php | 1 +
.../AnalyticsPodcastsByCountryModel.php | 5 +-
.../AnalyticsPodcastsByEpisodeModel.php | 113 ++
app/Models/AnalyticsPodcastsByPlayerModel.php | 117 +-
app/Models/AnalyticsPodcastsByRegionModel.php | 25 +
app/Models/AnalyticsPodcastsModel.php | 55 +
app/Models/AnalyticsWebsiteByBrowserModel.php | 1 -
.../AnalyticsWebsiteByEntryPageModel.php | 25 +
app/Models/AnalyticsWebsiteByRefererModel.php | 1 -
app/Models/EpisodeModel.php | 1 +
app/Models/PodcastModel.php | 1 +
app/Views/_assets/charts.ts | 4 +
.../_assets/images/logo-castopod-circle.svg | 26 +
app/Views/_assets/images/logo-castopod.svg | 124 +-
.../_assets/images/platforms/_default.svg | 31 +-
app/Views/_assets/modules/Charts.ts | 134 ++
app/Views/_layout.php | 2 +-
app/Views/admin/_layout.php | 6 +-
app/Views/admin/podcast/_sidebar.php | 2 +-
app/Views/admin/podcast/analytics.php | 32 +
composer.json | 12 +-
composer.lock | 146 +-
docs/setup-development.md | 7 +
package-lock.json | 1448 +++++++++++++++--
package.json | 2 +
public/favicon.ico | Bin 3758 -> 2686 bytes
61 files changed, 3163 insertions(+), 541 deletions(-)
create mode 100644 app/Controllers/Admin/AnalyticsData.php
create mode 100644 app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php
rename app/Database/Migrations/{2020-06-08-210000_add_analytics_episodes_by_country.php => 2020-06-08-130000_add_analytics_podcasts_by_episode.php} (67%)
rename app/Database/Migrations/{2020-06-08-210000_add_analytics_podcasts_by_player.php => 2020-06-08-140000_add_analytics_podcasts_by_player.php} (70%)
rename app/Database/Migrations/{2020-06-08-210000_add_analytics_podcasts_by_country.php => 2020-06-08-150000_add_analytics_podcasts_by_country.php} (83%)
rename app/Database/Migrations/{2020-06-08-210000_add_analytics_website_by_country.php => 2020-06-08-160000_add_analytics_podcasts_by_region.php} (60%)
rename app/Database/Migrations/{2020-06-08-210000_add_analytics_website_by_browser.php => 2020-06-08-170000_add_analytics_website_by_browser.php} (82%)
rename app/Database/Migrations/{2020-06-08-210000_add_analytics_website_by_referer.php => 2020-06-08-180000_add_analytics_website_by_referer.php} (78%)
create mode 100644 app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php
delete mode 100644 app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php
create mode 100644 app/Database/Seeds/FakePodcastsAnalyticsSeeder.php
create mode 100644 app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php
rename app/Entities/{AnalyticsWebsiteByCountry.php => AnalyticsPodcasts.php} (67%)
rename app/Entities/{AnalyticsEpisodesByPlayer.php => AnalyticsPodcastsByEpisode.php} (70%)
create mode 100644 app/Entities/AnalyticsPodcastsByRegion.php
rename app/Entities/{AnalyticsEpisodesByCountry.php => AnalyticsWebsiteByEntryPage.php} (62%)
create mode 100644 app/Models/AnalyticsPodcastsByEpisodeModel.php
create mode 100644 app/Models/AnalyticsPodcastsByRegionModel.php
create mode 100644 app/Models/AnalyticsPodcastsModel.php
create mode 100644 app/Models/AnalyticsWebsiteByEntryPageModel.php
create mode 100644 app/Views/_assets/charts.ts
create mode 100644 app/Views/_assets/images/logo-castopod-circle.svg
create mode 100644 app/Views/_assets/modules/Charts.ts
create mode 100644 app/Views/admin/podcast/analytics.php
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4b364873..9a5a0312 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -39,13 +39,11 @@ bundle_app:
script:
# build all assets for views
- npm run build
- # download GeoLite2-Country and opawg/user-agents archives and extract them to writable/uploads
- - wget -c "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=$MAXMIND_LICENCE_KEY&suffix=tar.gz" -O - | tar -xz -C ./writable/uploads/
- - wget -c "https://github.com/opawg/user-agents/archive/master.tar.gz" -O - | tar -xz -C ./writable/uploads/
+ # download GeoLite2-City archive and extract it to writable/uploads
+ - wget -c "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=$MAXMIND_LICENCE_KEY&suffix=tar.gz" -O - | tar -xz -C ./writable/uploads/
# rename extracted archives' folders
- - mv ./writable/uploads/GeoLite2-Country* ./writable/uploads/GeoLite2-Country
- - mv ./writable/uploads/user-agents* ./writable/uploads/user-agents
+ - mv ./writable/uploads/GeoLite2-City* ./writable/uploads/GeoLite2-City
# create bundle folder: uses .rsync-filter (-F) file to copy only needed files
- rsync -avF --progress . ./bundle
diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md
index df0c8a6e..19486297 100644
--- a/DEPENDENCIES.md
+++ b/DEPENDENCIES.md
@@ -12,16 +12,20 @@ PHP Dependencies:
- [commonmark](https://commonmark.thephpleague.com/) ([BSD 3-Clause "New" or "Revised" License](https://github.com/thephpleague/commonmark/blob/latest/LICENSE))
- [phpdotenv](https://github.com/vlucas/phpdotenv) ([ BSD-3-Clause License ](https://github.com/vlucas/phpdotenv/blob/master/LICENSE))
- [HTML To Markdown for PHP](https://github.com/thephpleague/html-to-markdown) ([MIT License](https://github.com/thephpleague/html-to-markdown/blob/master/LICENSE))
+- [podlibre/user-agents-php](https://github.com/podlibre/user-agents-php) ([MIT License](https://github.com/podlibre/user-agents-php/blob/main/LICENSE))
+- [podlibre/ipcat](https://github.com/podlibre/ipcat) ([GNU General Public License v3.0](https://github.com/podlibre/ipcat/blob/master/LICENSE))
Javascript dependencies:
- [rollup](https://rollupjs.org/) ([MIT License](https://github.com/rollup/rollup/blob/master/LICENSE.md))
- [tailwindcss](https://tailwindcss.com/) ([MIT License](https://github.com/tailwindcss/tailwindcss/blob/master/LICENSE))
- [ProseMirror](https://prosemirror.net/) ([MIT License](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE))
-- [D3: Data-Driven Documents](https://d3js.org) ([BSD 3-Clause "New" or "Revised" License](https://github.com/d3/d3/blob/master/LICENSE))
+- [amCharts 4](https://github.com/amcharts/amcharts4) ([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/) ([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
Other:
- [RemixIcon](https://remixicon.com/) ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License))
-- [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))
+- [OPAWG/User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))
+- [client9/ipcat](https://github.com/client9/ipcat) ([GNU General Public License v3.0](https://github.com/client9/ipcat/blob/master/LICENSE))
+- [GeoLite2 City](https://dev.maxmind.com/geoip/geoip2/geolite2/) ([Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://www.maxmind.com/en/geolite2/eula))
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 3a786da0..5d2c45f8 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -53,10 +53,14 @@ $routes->group(config('App')->installGateway, function ($routes) {
]);
});
-// Route for podcast audio file analytics (/stats/podcast_id/episode_id/podcast_folder/filename.mp3)
-$routes->add('stats/(:num)/(:num)/(:any)', 'Analytics::hit/$1/$2/$3', [
- 'as' => 'analytics_hit',
-]);
+// Route for podcast audio file analytics (/audio/podcast_id/episode_id/bytes_threshold/filesize/podcast_folder/filename.mp3)
+$routes->add(
+ 'audio/(:num)/(:num)/(:num)/(:num)/(:any)',
+ 'Analytics::hit/$1/$2/$3/$4/$5',
+ [
+ 'as' => 'analytics_hit',
+ ]
+);
// Show the Unknown UserAgents
$routes->get('.well-known/unknown-useragents', 'UnknownUserAgents');
@@ -113,6 +117,26 @@ $routes->group(
'as' => 'podcast-delete',
'filter' => 'permission:podcasts-delete',
]);
+ $routes->get('analytics', 'Podcast::analytics/$1', [
+ 'as' => 'podcast-analytics',
+ 'filter' => 'permission:podcasts-view,podcast-view',
+ ]);
+ $routes->get(
+ 'analytics-data/(:segment)/(:segment)',
+ 'AnalyticsData::getData/$1/$2/$3',
+ [
+ 'as' => 'analytics-data',
+ 'filter' => 'permission:podcasts-view,podcast-view',
+ ]
+ );
+ $routes->get(
+ 'analytics-data/(:segment)/(:segment)/(:num)',
+ 'AnalyticsData::getData/$1/$2/$3/$4',
+ [
+ 'as' => 'analytics-filtered-data',
+ 'filter' => 'permission:podcasts-view,podcast-view',
+ ]
+ );
// Podcast episodes
$routes->group('episodes', function ($routes) {
diff --git a/app/Controllers/Admin/AnalyticsData.php b/app/Controllers/Admin/AnalyticsData.php
new file mode 100644
index 00000000..a57960df
--- /dev/null
+++ b/app/Controllers/Admin/AnalyticsData.php
@@ -0,0 +1,69 @@
+ 2) {
+ if (!($this->podcast = (new PodcastModel())->find($params[0]))) {
+ throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(
+ 'Podcast not found: ' . $params[0]
+ );
+ }
+ $this->className = '\App\Models\Analytics' . $params[1] . 'Model';
+ $this->methodName = 'getData' . $params[2];
+ if (count($params) > 3) {
+ if (
+ !($this->episode = (new EpisodeModel())
+ ->where([
+ 'podcast_id' => $this->podcast->id,
+ 'id' => $params[3],
+ ])
+ ->first())
+ ) {
+ throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(
+ 'Episode not found: ' . $params[3]
+ );
+ }
+ }
+ }
+
+ return $this->$method();
+ }
+ public function getData()
+ {
+ $analytics_model = new $this->className();
+ $methodName = $this->methodName;
+ if ($this->episode) {
+ return $this->response->setJSON(
+ $analytics_model->$methodName(
+ $this->podcast->id,
+ $this->episode->id
+ )
+ );
+ } else {
+ return $this->response->setJSON(
+ $analytics_model->$methodName($this->podcast->id)
+ );
+ }
+ }
+}
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
index 439a86da..64594e07 100644
--- a/app/Controllers/Admin/Podcast.php
+++ b/app/Controllers/Admin/Podcast.php
@@ -58,6 +58,14 @@ class Podcast extends BaseController
return view('admin/podcast/view', $data);
}
+ public function analytics()
+ {
+ $data = ['podcast' => $this->podcast];
+
+ replace_breadcrumb_params([0 => $this->podcast->title]);
+ return view('admin/podcast/analytics', $data);
+ }
+
public function create()
{
helper(['form', 'misc']);
@@ -204,7 +212,9 @@ class Podcast extends BaseController
$podcast = new \App\Entities\Podcast([
'name' => $this->request->getPost('name'),
'imported_feed_url' => $this->request->getPost('imported_feed_url'),
-
+ 'new_feed_url' => base_url(
+ route_to('podcast_feed', $this->request->getPost('name'))
+ ),
'title' => $feed->channel[0]->title,
'description' => $feed->channel[0]->description,
'image' => download_file($nsItunes->image->attributes()),
@@ -214,7 +224,9 @@ class Podcast extends BaseController
? null
: (in_array($nsItunes->explicit, ['yes', 'true'])
? 'explicit'
- : null),
+ : (in_array($nsItunes->explicit, ['no', 'false'])
+ ? 'clean'
+ : null)),
'owner_name' => $nsItunes->owner->name,
'owner_email' => $nsItunes->owner->email,
'publisher' => $nsItunes->author,
@@ -302,11 +314,13 @@ class Podcast extends BaseController
'image' => empty($nsItunes->image->attributes())
? null
: download_file($nsItunes->image->attributes()),
- 'explicit' => $nsItunes->explicit
- ? (in_array($nsItunes->explicit, ['yes', 'true'])
+ 'parental_advisory' => empty($nsItunes->explicit)
+ ? null
+ : (in_array($nsItunes->explicit, ['yes', 'true'])
? 'explicit'
- : null)
- : null,
+ : (in_array($nsItunes->explicit, ['no', 'false'])
+ ? 'clean'
+ : null)),
'number' =>
$this->request->getPost('force_renumber') === 'yes'
? $itemNumber
diff --git a/app/Controllers/Analytics.php b/app/Controllers/Analytics.php
index ec89dc22..3482b1eb 100644
--- a/app/Controllers/Analytics.php
+++ b/app/Controllers/Analytics.php
@@ -40,16 +40,22 @@ class Analytics extends Controller
// E.g.:
// $this->session = \Config\Services::session();
- set_user_session_country();
+ set_user_session_deny_list_ip();
+ set_user_session_location();
set_user_session_player();
}
// Add one hit to this episode:
- public function hit($p_podcastId, $p_episodeId, ...$filename)
- {
+ public function hit(
+ $podcastId,
+ $episodeId,
+ $bytesThreshold,
+ $fileSize,
+ ...$filename
+ ) {
helper('media');
- podcast_hit($p_podcastId, $p_episodeId);
+ podcast_hit($podcastId, $episodeId, $bytesThreshold, $fileSize);
return redirect()->to(media_url(implode('/', $filename)));
}
}
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index 2f5bdcff..3bfbd4b0 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -45,9 +45,10 @@ class BaseController extends Controller
// E.g.:
// $this->session = \Config\Services::session();
- set_user_session_country();
+ set_user_session_deny_list_ip();
set_user_session_browser();
set_user_session_referer();
+ set_user_session_entry_page();
}
protected static function triggerWebpageHit($podcastId)
diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
index a95e4db1..018315cf 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -110,6 +110,13 @@ class AddPodcasts extends Migration
'The RSS feed URL if this podcast was imported, NULL otherwise.',
'null' => true,
],
+ 'new_feed_url' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 1024,
+ 'comment' =>
+ 'The RSS new feed URL if this podcast is moving out, NULL otherwise.',
+ 'null' => true,
+ ],
'created_at' => [
'type' => 'TIMESTAMP',
],
diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
index 24b2f02f..31334686 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -61,6 +61,12 @@ class AddEpisodes extends Migration
'unsigned' => true,
'comment' => 'File size in bytes',
],
+ 'enclosure_headersize' => [
+ 'type' => 'INT',
+ 'constraint' => 10,
+ 'unsigned' => true,
+ 'comment' => 'Header size in bytes',
+ ],
'description' => [
'type' => 'TEXT',
'null' => true,
diff --git a/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php b/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php
new file mode 100644
index 00000000..e31d932d
--- /dev/null
+++ b/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php
@@ -0,0 +1,49 @@
+forge->addField([
+ 'podcast_id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ ],
+ 'date' => [
+ 'type' => 'date',
+ ],
+ 'hits' => [
+ 'type' => 'INT',
+ 'constraint' => 10,
+ 'default' => 1,
+ ],
+ ]);
+ $this->forge->addPrimaryKey(['podcast_id', 'date']);
+ $this->forge->addField(
+ '`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
+ );
+ $this->forge->addField(
+ '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
+ );
+ $this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
+ $this->forge->createTable('analytics_podcasts');
+ }
+
+ public function down()
+ {
+ $this->forge->dropTable('analytics_podcasts');
+ }
+}
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php b/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php
similarity index 67%
rename from app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php
rename to app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php
index d0e94c6c..b189ef06 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php
+++ b/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php
@@ -12,34 +12,28 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
-class AddAnalyticsEpisodesByCountry extends Migration
+class AddAnalyticsPodcastsByEpisode extends Migration
{
public function up()
{
$this->forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
+ 'date' => [
+ 'type' => 'date',
+ ],
'episode_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
- 'country_code' => [
- 'type' => 'VARCHAR',
- 'constraint' => 3,
- 'comment' => 'ISO 3166-1 code.',
- ],
- 'date' => [
- 'type' => 'date',
+ 'age' => [
+ 'type' => 'INT',
+ 'constraint' => 10,
+ 'unsigned' => true,
],
'hits' => [
'type' => 'INT',
@@ -47,13 +41,7 @@ class AddAnalyticsEpisodesByCountry extends Migration
'default' => 1,
],
]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey([
- 'podcast_id',
- 'episode_id',
- 'country_code',
- 'date',
- ]);
+ $this->forge->addPrimaryKey(['podcast_id', 'episode_id', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
@@ -62,11 +50,11 @@ class AddAnalyticsEpisodesByCountry extends Migration
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('episode_id', 'episodes', 'id');
- $this->forge->createTable('analytics_episodes_by_country');
+ $this->forge->createTable('analytics_podcasts_by_episode');
}
public function down()
{
- $this->forge->dropTable('analytics_episodes_by_country');
+ $this->forge->dropTable('analytics_podcasts_by_episode');
}
}
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php b/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php
similarity index 70%
rename from app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php
rename to app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php
index 3a13f65f..c1bc04af 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php
+++ b/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php
@@ -17,32 +17,45 @@ class AddAnalyticsPodcastsByPlayer extends Migration
public function up()
{
$this->forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
- 'player' => [
- 'type' => 'VARCHAR',
- 'constraint' => 191,
- ],
'date' => [
'type' => 'date',
],
+ 'app' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 128,
+ ],
+ 'device' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 32,
+ ],
+ 'os' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 32,
+ ],
+ 'bot' => [
+ 'type' => 'TINYINT',
+ 'constraint' => 1,
+ 'default' => 0,
+ ],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey(['podcast_id', 'player', 'date']);
+ $this->forge->addPrimaryKey([
+ 'podcast_id',
+ 'app',
+ 'device',
+ 'os',
+ 'bot',
+ 'date',
+ ]);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php b/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php
similarity index 83%
rename from app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php
rename to app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php
index 6545a7a1..e5f045e6 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php
+++ b/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php
@@ -17,33 +17,26 @@ class AddAnalyticsPodcastsByCountry extends Migration
public function up()
{
$this->forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
+ 'date' => [
+ 'type' => 'date',
+ ],
'country_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-1 code.',
],
- 'date' => [
- 'type' => 'date',
- ],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey(['podcast_id', 'country_code', 'date']);
+ $this->forge->addPrimaryKey(['podcast_id', 'country_code', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php b/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php
similarity index 60%
rename from app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php
rename to app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php
index 7f8b1415..7b787878 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php
+++ b/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php
@@ -1,8 +1,8 @@
forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
+ 'date' => [
+ 'type' => 'date',
+ ],
'country_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-1 code.',
],
- 'date' => [
- 'type' => 'date',
+ 'region_code' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 3,
+ 'comment' => 'ISO 3166-2 code.',
+ ],
+ 'latitude' => [
+ 'type' => 'FLOAT',
+ 'null' => true,
+ ],
+ 'longitude' => [
+ 'type' => 'FLOAT',
+ 'null' => true,
],
'hits' => [
'type' => 'INT',
@@ -42,8 +49,12 @@ class AddAnalyticsWebsiteByCountry extends Migration
'default' => 1,
],
]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey(['podcast_id', 'country_code', 'date']);
+ $this->forge->addPrimaryKey([
+ 'podcast_id',
+ 'country_code',
+ 'region_code',
+ 'date',
+ ]);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
@@ -51,11 +62,11 @@ class AddAnalyticsWebsiteByCountry extends Migration
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
- $this->forge->createTable('analytics_website_by_country');
+ $this->forge->createTable('analytics_podcasts_by_region');
}
public function down()
{
- $this->forge->dropTable('analytics_website_by_country');
+ $this->forge->dropTable('analytics_podcasts_by_region');
}
}
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php b/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php
similarity index 82%
rename from app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php
rename to app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php
index 6e4942d4..21724af7 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php
+++ b/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php
@@ -17,32 +17,26 @@ class AddAnalyticsWebsiteByBrowser extends Migration
public function up()
{
$this->forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
+ 'date' => [
+ 'type' => 'date',
+ ],
'browser' => [
'type' => 'VARCHAR',
'constraint' => 191,
],
- 'date' => [
- 'type' => 'date',
- ],
+
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey(['podcast_id', 'browser', 'date']);
+ $this->forge->addPrimaryKey(['podcast_id', 'browser', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php b/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php
similarity index 78%
rename from app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php
rename to app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php
index 28808f27..579024b0 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php
+++ b/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php
@@ -17,33 +17,36 @@ class AddAnalyticsWebsiteByReferer extends Migration
public function up()
{
$this->forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
- 'referer' => [
- 'type' => 'VARCHAR',
- 'constraint' => 191,
- 'comment' => 'Referer URL.',
- ],
'date' => [
'type' => 'date',
],
+ 'referer' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 512,
+ 'comment' => 'Referer URL.',
+ ],
+ 'domain' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 128,
+ 'null' => true,
+ ],
+ 'keywords' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 384,
+ 'null' => true,
+ ],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey(['podcast_id', 'referer', 'date']);
+ $this->forge->addPrimaryKey(['podcast_id', 'referer', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
diff --git a/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php b/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php
new file mode 100644
index 00000000..19bce6de
--- /dev/null
+++ b/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php
@@ -0,0 +1,54 @@
+forge->addField([
+ 'podcast_id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ ],
+ 'date' => [
+ 'type' => 'date',
+ ],
+ 'entry_page' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 512,
+ 'comment' => 'Entry page URL.',
+ ],
+ 'hits' => [
+ 'type' => 'INT',
+ 'constraint' => 10,
+ 'default' => 1,
+ ],
+ ]);
+ $this->forge->addPrimaryKey(['podcast_id', 'entry_page', 'date']);
+ $this->forge->addField(
+ '`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
+ );
+ $this->forge->addField(
+ '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
+ );
+ $this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
+ $this->forge->createTable('analytics_website_by_entry_page');
+ }
+
+ public function down()
+ {
+ $this->forge->dropTable('analytics_website_by_entry_page');
+ }
+}
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php
deleted file mode 100644
index 3a1e257a..00000000
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php
+++ /dev/null
@@ -1,71 +0,0 @@
-forge->addField([
- 'id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- 'auto_increment' => true,
- ],
- 'podcast_id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- ],
- 'episode_id' => [
- 'type' => 'BIGINT',
- 'constraint' => 20,
- 'unsigned' => true,
- ],
- 'player' => [
- 'type' => 'VARCHAR',
- 'constraint' => 191,
- ],
- 'date' => [
- 'type' => 'date',
- ],
- 'hits' => [
- 'type' => 'INT',
- 'constraint' => 10,
- 'default' => 1,
- ],
- ]);
- $this->forge->addKey('id', true);
- $this->forge->addUniqueKey([
- 'podcast_id',
- 'episode_id',
- 'player',
- 'date',
- ]);
- $this->forge->addField(
- '`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
- );
- $this->forge->addField(
- '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
- );
- $this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
- $this->forge->addForeignKey('episode_id', 'episodes', 'id');
- $this->forge->createTable('analytics_episodes_by_player');
- }
-
- public function down()
- {
- $this->forge->dropTable('analytics_episodes_by_player');
- }
-}
diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_stored_procedure.php b/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_stored_procedure.php
index 18bd203a..caf35500 100644
--- a/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_stored_procedure.php
+++ b/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_stored_procedure.php
@@ -18,26 +18,42 @@ class AddAnalyticsPodcastsStoredProcedure extends Migration
{
// Creates Stored Procedure for data insertion
// Example: CALL analytics_podcasts(1,2,'FR','phone/android/Deezer');
- $procedureName = $this->db->prefixTable('analytics_podcasts');
- $episodesTableName = $this->db->prefixTable('analytics_episodes');
+ $prefix = $this->db->getPrefix();
+
$createQuery = <<db->query($createQuery);
@@ -45,7 +61,9 @@ EOD;
public function down()
{
- $procedureName = $this->db->prefixTable('analytics_podcasts');
- $this->db->query("DROP PROCEDURE IF EXISTS `$procedureName`");
+ $prefix = $this->db->getPrefix();
+ $this->db->query(
+ "DROP PROCEDURE IF EXISTS `{$prefix}analytics_podcasts`"
+ );
}
}
diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_website_stored_procedure.php b/app/Database/Migrations/2020-06-11-210000_add_analytics_website_stored_procedure.php
index c263a8a1..836b5d7d 100644
--- a/app/Database/Migrations/2020-06-11-210000_add_analytics_website_stored_procedure.php
+++ b/app/Database/Migrations/2020-06-11-210000_add_analytics_website_stored_procedure.php
@@ -20,20 +20,20 @@ class AddAnalyticsWebsiteStoredProcedure extends Migration
// Example: CALL analytics_website(1,'FR','Firefox');
$procedureName = $this->db->prefixTable('analytics_website');
$createQuery = <<db->query($createQuery);
diff --git a/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php b/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php
new file mode 100644
index 00000000..e312a3a0
--- /dev/null
+++ b/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php
@@ -0,0 +1,176 @@
+first();
+
+ $jsonUserAgents = json_decode(
+ file_get_contents(
+ 'https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json'
+ ),
+ true
+ );
+
+ if ($podcast) {
+ $firstEpisode = (new EpisodeModel())
+ ->selectMin('published_at')
+ ->first();
+
+ for (
+ $date = strtotime($firstEpisode->published_at);
+ $date < strtotime('now');
+ $date = strtotime(date('Y-m-d', $date) . ' +1 day')
+ ) {
+ $analytics_podcasts = [];
+ $analytics_podcasts_by_country = [];
+ $analytics_podcasts_by_episode = [];
+ $analytics_podcasts_by_player = [];
+ $analytics_podcasts_by_region = [];
+
+ $episodes = (new EpisodeModel())
+ ->where([
+ 'podcast_id' => $podcast->id,
+ 'DATE(published_at) <=' => date('Y-m-d', $date),
+ ])
+ ->findAll();
+ foreach ($episodes as $episode) {
+ $age = floor(
+ ($date - strtotime($episode->published_at)) / 86400
+ );
+ $proba1 = floor(exp(3 - $age / 40)) + 1;
+
+ for (
+ $num_line = 0;
+ $num_line < rand(1, $proba1);
+ $num_line++
+ ) {
+ $proba2 = floor(exp(6 - $age / 20)) + 10;
+
+ $player =
+ $jsonUserAgents[
+ rand(1, count($jsonUserAgents) - 1)
+ ];
+ $app = isset($player['app']) ? $player['app'] : '';
+ $device = isset($player['device'])
+ ? $player['device']
+ : '';
+ $os = isset($player['os']) ? $player['os'] : '';
+ $bot = isset($player['bot']) ? $player['bot'] : 0;
+
+ $fakeIp =
+ rand(0, 255) .
+ '.' .
+ rand(0, 255) .
+ '.' .
+ rand(0, 255) .
+ '.' .
+ rand(0, 255);
+
+ $cityReader = new \GeoIp2\Database\Reader(
+ WRITEPATH .
+ 'uploads/GeoLite2-City/GeoLite2-City.mmdb'
+ );
+
+ $countryCode = 'N/A';
+ $regionCode = 'N/A';
+ $latitude = null;
+ $longitude = null;
+ try {
+ $city = $cityReader->city($fakeIp);
+
+ $countryCode = empty($city->country->isoCode)
+ ? 'N/A'
+ : $city->country->isoCode;
+
+ $regionCode = empty($city->subdivisions[0]->isoCode)
+ ? 'N/A'
+ : $city->subdivisions[0]->isoCode;
+ $latitude = round($city->location->latitude, 3);
+ $longitude = round($city->location->longitude, 3);
+ } catch (\GeoIp2\Exception\AddressNotFoundException $ex) {
+ //Bad luck, bad IP, nothing to do.
+ }
+
+ $hits = rand(0, $proba2);
+
+ $analytics_podcasts[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'hits' => $hits,
+ ];
+ $analytics_podcasts_by_country[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'country_code' => $countryCode,
+ 'hits' => $hits,
+ ];
+ $analytics_podcasts_by_episode[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'episode_id' => $episode->id,
+ 'age' => $age,
+ 'hits' => $hits,
+ ];
+ $analytics_podcasts_by_player[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'app' => $app,
+ 'device' => $device,
+ 'os' => $os,
+ 'bot' => $bot,
+ 'hits' => $hits,
+ ];
+ $analytics_podcasts_by_region[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'country_code' => $countryCode,
+ 'region_code' => $regionCode,
+ 'latitude' => $latitude,
+ 'longitude' => $longitude,
+ 'hits' => $hits,
+ ];
+ }
+ }
+ $this->db
+ ->table('analytics_podcasts')
+ ->ignore(true)
+ ->insertBatch($analytics_podcasts);
+ $this->db
+ ->table('analytics_podcasts_by_country')
+ ->ignore(true)
+ ->insertBatch($analytics_podcasts_by_country);
+ $this->db
+ ->table('analytics_podcasts_by_episode')
+ ->ignore(true)
+ ->insertBatch($analytics_podcasts_by_episode);
+ $this->db
+ ->table('analytics_podcasts_by_player')
+ ->ignore(true)
+ ->insertBatch($analytics_podcasts_by_player);
+ $this->db
+ ->table('analytics_podcasts_by_region')
+ ->ignore(true)
+ ->insertBatch($analytics_podcasts_by_region);
+ }
+ } else {
+ echo "Create one podcast and some episodes first.\n";
+ }
+ }
+}
diff --git a/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php b/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php
new file mode 100644
index 00000000..67270d4c
--- /dev/null
+++ b/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php
@@ -0,0 +1,260 @@
+first();
+
+ if ($podcast) {
+ $firstEpisode = (new EpisodeModel())
+ ->selectMin('published_at')
+ ->first();
+
+ for (
+ $date = strtotime($firstEpisode->published_at);
+ $date < strtotime('now');
+ $date = strtotime(date('Y-m-d', $date) . ' +1 day')
+ ) {
+ $website_by_browser = [];
+ $website_by_entry_page = [];
+ $website_by_referer = [];
+
+ $episodes = (new EpisodeModel())
+ ->where([
+ 'podcast_id' => $podcast->id,
+ 'DATE(published_at) <=' => date('Y-m-d', $date),
+ ])
+ ->findAll();
+ foreach ($episodes as $episode) {
+ $age = floor(
+ ($date - strtotime($episode->published_at)) / 86400
+ );
+ $proba1 = floor(exp(3 - $age / 40)) + 1;
+
+ for (
+ $num_line = 0;
+ $num_line < rand(1, $proba1);
+ $num_line++
+ ) {
+ $proba2 = floor(exp(6 - $age / 20)) + 10;
+
+ $domain =
+ $this->domains[rand(0, count($this->domains) - 1)];
+ $keyword =
+ $this->keywords[
+ rand(0, count($this->keywords) - 1)
+ ];
+ $browser =
+ $this->browsers[
+ rand(0, count($this->browsers) - 1)
+ ];
+
+ $hits = rand(0, $proba2);
+
+ $website_by_browser[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'browser' => $browser,
+ 'hits' => $hits,
+ ];
+ $website_by_entry_page[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'entry_page' => $episode->link,
+ 'hits' => $hits,
+ ];
+ $website_by_referer[] = [
+ 'podcast_id' => $podcast->id,
+ 'date' => date('Y-m-d', $date),
+ 'referer' =>
+ 'http://' . $domain . '/?q=' . $keyword,
+ 'domain' => $domain,
+ 'keywords' => $keyword,
+ 'hits' => $hits,
+ ];
+ }
+ }
+ $this->db
+ ->table('analytics_website_by_browser')
+ ->ignore(true)
+ ->insertBatch($website_by_browser);
+ $this->db
+ ->table('analytics_website_by_entry_page')
+ ->ignore(true)
+ ->insertBatch($website_by_entry_page);
+ $this->db
+ ->table('analytics_website_by_referer')
+ ->ignore(true)
+ ->insertBatch($website_by_referer);
+ }
+ } else {
+ echo "Create one podcast and some episodes first.\n";
+ }
+ }
+}
diff --git a/app/Entities/AnalyticsWebsiteByCountry.php b/app/Entities/AnalyticsPodcasts.php
similarity index 67%
rename from app/Entities/AnalyticsWebsiteByCountry.php
rename to app/Entities/AnalyticsPodcasts.php
index 9839e3cb..7f0f169e 100644
--- a/app/Entities/AnalyticsWebsiteByCountry.php
+++ b/app/Entities/AnalyticsPodcasts.php
@@ -1,8 +1,8 @@
'integer',
- 'country_code' => 'string',
'date' => 'datetime',
'hits' => 'integer',
];
diff --git a/app/Entities/AnalyticsEpisodesByPlayer.php b/app/Entities/AnalyticsPodcastsByEpisode.php
similarity index 70%
rename from app/Entities/AnalyticsEpisodesByPlayer.php
rename to app/Entities/AnalyticsPodcastsByEpisode.php
index 3e48c0aa..783bf2d5 100644
--- a/app/Entities/AnalyticsEpisodesByPlayer.php
+++ b/app/Entities/AnalyticsPodcastsByEpisode.php
@@ -1,8 +1,8 @@
'integer',
'episode_id' => 'integer',
- 'player' => 'string',
'date' => 'datetime',
'hits' => 'integer',
];
diff --git a/app/Entities/AnalyticsPodcastsByPlayer.php b/app/Entities/AnalyticsPodcastsByPlayer.php
index 9e33ba98..b0e19d26 100644
--- a/app/Entities/AnalyticsPodcastsByPlayer.php
+++ b/app/Entities/AnalyticsPodcastsByPlayer.php
@@ -16,7 +16,10 @@ class AnalyticsPodcastsByPlayer extends Entity
{
protected $casts = [
'podcast_id' => 'integer',
- 'player' => 'string',
+ 'app' => '?string',
+ 'device' => '?string',
+ 'os' => '?string',
+ 'bot' => 'boolean',
'date' => 'datetime',
'hits' => 'integer',
];
diff --git a/app/Entities/AnalyticsPodcastsByRegion.php b/app/Entities/AnalyticsPodcastsByRegion.php
new file mode 100644
index 00000000..8f6a9d60
--- /dev/null
+++ b/app/Entities/AnalyticsPodcastsByRegion.php
@@ -0,0 +1,26 @@
+ 'integer',
+ 'country_code' => 'string',
+ 'region_code' => '?string',
+ 'latitude' => '?float',
+ 'longitude' => '?float',
+ 'date' => 'datetime',
+ 'hits' => 'integer',
+ ];
+}
diff --git a/app/Entities/AnalyticsEpisodesByCountry.php b/app/Entities/AnalyticsWebsiteByEntryPage.php
similarity index 62%
rename from app/Entities/AnalyticsEpisodesByCountry.php
rename to app/Entities/AnalyticsWebsiteByEntryPage.php
index b1736443..344d60fb 100644
--- a/app/Entities/AnalyticsEpisodesByCountry.php
+++ b/app/Entities/AnalyticsWebsiteByEntryPage.php
@@ -1,8 +1,8 @@
'integer',
- 'episode_id' => 'integer',
- 'country_code' => 'string',
+ 'entry_page' => '?string',
'date' => 'datetime',
'hits' => 'integer',
];
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 7bbda5e5..26f0ee5c 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -64,6 +64,7 @@ class Episode extends Entity
'enclosure_duration' => 'integer',
'enclosure_mimetype' => 'string',
'enclosure_filesize' => 'integer',
+ 'enclosure_headersize' => 'integer',
'description' => 'string',
'image_uri' => '?string',
'parental_advisory' => '?string',
@@ -143,6 +144,8 @@ class Episode extends Entity
$enclosure_metadata['mime_type'];
$this->attributes['enclosure_filesize'] =
$enclosure_metadata['filesize'];
+ $this->attributes['enclosure_headersize'] =
+ $enclosure_metadata['avdataoffset'];
return $this;
}
@@ -167,6 +170,19 @@ class Episode extends Entity
'analytics_hit',
$this->attributes['podcast_id'],
$this->attributes['id'],
+ // bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics
+ // - if file is shorter than 60sec, then it's enclosure_filesize
+ // - if file is longer than 60 seconds then it's enclosure_headersize + 60 seconds
+ $this->attributes['enclosure_duration'] <= 60
+ ? $this->attributes['enclosure_filesize']
+ : $this->attributes['enclosure_headersize'] +
+ floor(
+ (($this->attributes['enclosure_filesize'] -
+ $this->attributes['enclosure_headersize']) /
+ $this->attributes['enclosure_duration']) *
+ 60
+ ),
+ $this->attributes['enclosure_filesize'],
$this->attributes['enclosure_uri']
)
);
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index c0dd2b7d..f60e44dc 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -82,6 +82,7 @@ class Podcast extends Entity
'created_by' => 'integer',
'updated_by' => 'integer',
'imported_feed_url' => '?string',
+ 'new_feed_url' => '?string',
];
/**
diff --git a/app/Helpers/analytics_helper.php b/app/Helpers/analytics_helper.php
index d0ca06cb..51561e95 100644
--- a/app/Helpers/analytics_helper.php
+++ b/app/Helpers/analytics_helper.php
@@ -33,25 +33,56 @@ if (!function_exists('getallheaders')) {
/**
* Set user country in session variable, for analytics purpose
*/
-function set_user_session_country()
+function set_user_session_deny_list_ip()
{
$session = \Config\Services::session();
$session->start();
- $country = 'N/A';
+ if (!$session->has('denyListIp')) {
+ $session->set(
+ 'denyListIp',
+ \Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null
+ );
+ }
+}
- // Finds country:
- if (!$session->has('country')) {
+/**
+ * Set user country in session variable, for analytics purpose
+ */
+function set_user_session_location()
+{
+ $session = \Config\Services::session();
+ $session->start();
+
+ $location = [
+ 'countryCode' => 'N/A',
+ 'regionCode' => 'N/A',
+ 'latitude' => null,
+ 'longitude' => null,
+ ];
+
+ // Finds location:
+ if (!$session->has('location')) {
try {
- $reader = new \GeoIp2\Database\Reader(
- WRITEPATH . 'uploads/GeoLite2-Country/GeoLite2-Country.mmdb'
+ $cityReader = new \GeoIp2\Database\Reader(
+ WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb'
);
- $geoip = $reader->country($_SERVER['REMOTE_ADDR']);
- $country = $geoip->country->isoCode;
+ $city = $cityReader->city($_SERVER['REMOTE_ADDR']);
+
+ $location = [
+ 'countryCode' => empty($city->country->isoCode)
+ ? 'N/A'
+ : $city->country->isoCode,
+ 'regionCode' => empty($city->subdivisions[0]->isoCode)
+ ? 'N/A'
+ : $city->subdivisions[0]->isoCode,
+ 'latitude' => round($city->location->latitude, 3),
+ 'longitude' => round($city->location->longitude, 3),
+ ];
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
- $session->set('country', $country);
+ $session->set('location', $location);
}
}
@@ -67,58 +98,36 @@ function set_user_session_player()
$session = \Config\Services::session();
$session->start();
- $playerName = '- Unknown Player -';
-
- $useragent = $_SERVER['HTTP_USER_AGENT'];
+ $playerFound = null;
+ $userAgent = $_SERVER['HTTP_USER_AGENT'];
try {
- $jsonUserAgents = json_decode(
- file_get_contents(
- WRITEPATH . 'uploads/user-agents/src/user-agents.json'
- ),
- true
- );
-
- //Search for current HTTP_USER_AGENT in json file:
- foreach ($jsonUserAgents as $player) {
- foreach ($player['user_agents'] as $useragentsRegexp) {
- //Does the HTTP_USER_AGENT match this regexp:
- if (preg_match("#{$useragentsRegexp}#", $useragent)) {
- if (isset($player['bot'])) {
- //It’s a bot!
- $playerName = '- Bot -';
- } else {
- //It isn’t a bot, we store device/os/app:
- $playerName =
- (isset($player['device'])
- ? $player['device'] . '/'
- : '') .
- (isset($player['os'])
- ? $player['os'] . '/'
- : '') .
- (isset($player['app']) ? $player['app'] : '?');
- }
- //We found it!
- break 2;
- }
- }
- }
+ $playerFound = \Podlibre\UserAgentsPhp\UserAgents::find($userAgent);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
- if ($playerName == '- Unknown Player -') {
+ if ($playerFound) {
+ $session->set('player', $playerFound);
+ } else {
+ $session->set('player', [
+ 'app' => '- unknown -',
+ 'device' => '',
+ 'os' => '',
+ 'bot' => 0,
+ ]);
// Add to unknown list
try {
$db = \Config\Database::connect();
- $procedureNameAUU = $db->prefixTable(
+ $procedureNameAnalyticsUnknownUseragents = $db->prefixTable(
'analytics_unknown_useragents'
);
- $db->query("CALL $procedureNameAUU(?)", [$useragent]);
+ $db->query("CALL $procedureNameAnalyticsUnknownUseragents(?)", [
+ $userAgent,
+ ]);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
}
- $session->set('player', $playerName);
}
}
@@ -165,49 +174,149 @@ function set_user_session_referer()
}
}
+/**
+ * Set user entry page in session variable, for analytics purpose
+ */
+function set_user_session_entry_page()
+{
+ $session = \Config\Services::session();
+ $session->start();
+
+ $entryPage = $_SERVER['REQUEST_URI'];
+ if (!$session->has('entryPage')) {
+ $session->set('entryPage', $entryPage);
+ }
+}
+
function webpage_hit($podcast_id)
{
$session = \Config\Services::session();
$session->start();
- $db = \Config\Database::connect();
- $procedureName = $db->prefixTable('analytics_website');
- $db->query("call $procedureName(?,?,?,?)", [
- $podcast_id,
- $session->get('country'),
- $session->get('browser'),
- $session->get('referer'),
- ]);
+ if (!$session->get('denyListIp')) {
+ $db = \Config\Database::connect();
+
+ $referer = $session->get('referer');
+ $domain = empty(parse_url($referer, PHP_URL_HOST))
+ ? null
+ : parse_url($referer, PHP_URL_HOST);
+ parse_str(parse_url($referer, PHP_URL_QUERY), $queries);
+ $keywords = empty($queries['q']) ? null : $queries['q'];
+
+ $procedureName = $db->prefixTable('analytics_website');
+ $db->query("call $procedureName(?,?,?,?,?,?)", [
+ $podcast_id,
+ $session->get('browser'),
+ $session->get('entryPage'),
+ $referer,
+ $domain,
+ $keywords,
+ ]);
+ }
}
-function podcast_hit($p_podcast_id, $p_episode_id)
+/**
+ * Counting podcast episode downloads for analytics purposes
+ * ✅ No IP address is ever stored on the server.
+ * ✅ Only aggregate data is stored in the database.
+ * We follow IAB Podcast Measurement Technical Guidelines Version 2.0:
+ * https://iabtechlab.com/standards/podcast-measurement-guidelines/
+ * https://iabtechlab.com/wp-content/uploads/2017/12/Podcast_Measurement_v2-Dec-20-2017.pdf
+ * ✅ Rolling 24-hour window
+ * ✅ Castopod does not do pre-load
+ * ✅ IP deny list https://github.com/client9/ipcat
+ * ✅ User-agent Filtering https://github.com/opawg/user-agents
+ * ✅ Ignores 2 bytes range "Range: 0-1" (performed by official Apple iOS Podcast app)
+ * ✅ In case of partial content, adds up all requests to check >1mn was downloaded
+ * ✅ Identifying Uniques is done with a combination of IP Address and User Agent
+ * @param int $podcastId The podcast ID
+ * @param int $episodeId The Episode ID
+ * @param int $bytesThreshold The minimum total number of bytes that must be downloaded so that an episode is counted (>1mn)
+ * @param int $fileSize The podcast complete file size
+ *
+ * @return void
+ */
+function podcast_hit($podcastId, $episodeId, $bytesThreshold, $fileSize)
{
$session = \Config\Services::session();
$session->start();
- $first_time_for_this_episode = true;
- if ($session->has('episodes')) {
- if (in_array($p_episode_id, $session->get('episodes'))) {
- $first_time_for_this_episode = false;
+ // We try to count (but if things went wrong the show should go on and the user should be able to download the file):
+ try {
+ // If the user IP is denied it's probably a bot:
+ if ($session->get('denyListIp')) {
+ $session->get('player')['bot'] = true;
+ }
+ $httpRange = $_SERVER['HTTP_RANGE'];
+ // We create a sha1 hash for this IP_Address+User_Agent+Episode_ID:
+ $hashID =
+ '_IpUaEp_' .
+ sha1(
+ $_SERVER['REMOTE_ADDR'] .
+ '_' .
+ $_SERVER['HTTP_USER_AGENT'] .
+ '_' .
+ $episodeId
+ );
+ // Was this episode downloaded in the past 24h:
+ $downloadedBytes = cache($hashID);
+ // Rolling window is 24 hours (86400 seconds):
+ $ttl = 86400;
+ if ($downloadedBytes) {
+ // In case it was already downloaded, TTL should be adjusted (rolling window is 24h since 1st download):
+ $ttl = cache()->getMetadata($hashID)['expire'] - time();
} else {
- $session->push('episodes', [$p_episode_id]);
+ // If it was never downloaded that means that zero byte were downloaded:
+ $downloadedBytes = 0;
}
- } else {
- $session->set('episodes', [$p_episode_id]);
- }
+ // If the number of downloaded bytes was previously below the 1mn threshold we go on:
+ // (Otherwise it means that this was already counted, therefore we don't do anything)
+ if ($downloadedBytes < $bytesThreshold) {
+ // If HTTP_RANGE is null we are downloading the complete file:
+ if (!isset($httpRange)) {
+ $downloadedBytes = $fileSize;
+ } else {
+ // [0-1] bytes range requests are used (by Apple) to check that file exists and that 206 partial content is working.
+ // We don't count these requests:
+ if ($httpRange != 'bytes=0-1') {
+ // We calculate how many bytes are being downloaded based on HTTP_RANGE values:
+ $ranges = explode(',', substr($httpRange, 6));
+ foreach ($ranges as $range) {
+ $parts = explode('-', $range);
+ $downloadedBytes += empty($parts[1])
+ ? $fileSize
+ : $parts[1] - (empty($parts[0]) ? 0 : $parts[0]);
+ }
+ }
+ }
+ // We save the number of downloaded bytes for this user and this episode:
+ cache()->save($hashID, $downloadedBytes, $ttl);
- if ($first_time_for_this_episode) {
- $db = \Config\Database::connect();
- $procedureName = $db->prefixTable('analytics_podcasts');
- try {
- $db->query("CALL $procedureName(?,?,?,?);", [
- $p_podcast_id,
- $p_episode_id,
- $session->get('country'),
- $session->get('player'),
- ]);
- } catch (\Exception $e) {
- // If things go wrong the show must go on and the user must be able to download the file
+ // If more that 1mn was downloaded, we send that to the database:
+ if ($downloadedBytes >= $bytesThreshold) {
+ $db = \Config\Database::connect();
+ $procedureName = $db->prefixTable('analytics_podcasts');
+
+ $app = $session->get('player')['app'];
+ $device = $session->get('player')['device'];
+ $os = $session->get('player')['os'];
+ $bot = $session->get('player')['bot'];
+
+ $db->query("CALL $procedureName(?,?,?,?,?,?,?,?,?,?);", [
+ $podcastId,
+ $episodeId,
+ $session->get('location')['countryCode'],
+ $session->get('location')['regionCode'],
+ $session->get('location')['latitude'],
+ $session->get('location')['longitude'],
+ $app == null ? '' : $app,
+ $device == null ? '' : $device,
+ $os == null ? '' : $os,
+ $bot == null ? 0 : $bot,
+ ]);
+ }
}
+ } catch (\Exception $e) {
+ // If things go wrong the show must go on and the user must be able to download the file
}
}
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
index a7c1685a..61c21e2e 100644
--- a/app/Helpers/id3_helper.php
+++ b/app/Helpers/id3_helper.php
@@ -24,6 +24,7 @@ function get_file_tags($file)
return [
'filesize' => $FileInfo['filesize'],
'mime_type' => $FileInfo['mime_type'],
+ 'avdataoffset' => $FileInfo['avdataoffset'],
'playtime_seconds' => $FileInfo['playtime_seconds'],
];
}
@@ -68,7 +69,11 @@ function write_enclosure_tags($episode)
'comment' => [$episode->description],
'track_number' => [strval($episode->number)],
'copyright_message' => [$episode->podcast->copyright],
- 'publisher' => ['Podlibre'],
+ 'publisher' => [
+ empty($episode->podcast->publisher)
+ ? $episode->podcast->owner_name
+ : $episode->podcast->publisher,
+ ],
'encoded_by' => ['Castopod'],
// TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index 532b9bcb..9f17a2f7 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -36,6 +36,14 @@ function get_rss_feed($podcast)
$atom_link->addAttribute('rel', 'self');
$atom_link->addAttribute('type', 'application/rss+xml');
+ if (!empty($podcast->new_feed_url)) {
+ $channel->addChild(
+ 'new-feed-url',
+ $podcast->new_feed_url,
+ $itunes_namespace
+ );
+ }
+
// the last build date corresponds to the creation of the feed.xml cache
$channel->addChild(
'lastBuildDate',
@@ -50,7 +58,7 @@ function get_rss_feed($podcast)
$channel->addChild('title', $podcast->title);
$channel->addChildWithCDATA('description', $podcast->description_html);
$itunes_image = $channel->addChild('image', null, $itunes_namespace);
- $itunes_image->addAttribute('href', $podcast->image->url);
+ $itunes_image->addAttribute('href', $podcast->image->original_url);
$channel->addChild('language', $podcast->language);
// set main category first, then other categories as apple
diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php
index 70e5beb9..5827731b 100644
--- a/app/Language/en/Breadcrumb.php
+++ b/app/Language/en/Breadcrumb.php
@@ -22,4 +22,5 @@ return [
'import' => 'feed import',
'settings' => 'settings',
'platforms' => 'platforms',
+ 'analytics' => 'Analytics',
];
diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php
index 8a86df06..fc54298c 100644
--- a/app/Language/en/Podcast.php
+++ b/app/Language/en/Podcast.php
@@ -12,7 +12,7 @@ return [
'create' => 'Create a podcast',
'import' => 'Import a podcast',
'new_episode' => 'New Episode',
- 'feed' => 'RSS feed',
+ 'feed' => 'RSS',
'view' => 'View podcast',
'edit' => 'Edit podcast',
'delete' => 'Delete podcast',
diff --git a/app/Language/en/PodcastImport.php b/app/Language/en/PodcastImport.php
index 6b86eb16..3bb3a912 100644
--- a/app/Language/en/PodcastImport.php
+++ b/app/Language/en/PodcastImport.php
@@ -7,6 +7,12 @@
*/
return [
+ 'legal_dislaimer_title' => 'Legal Disclaimer',
+ 'legal_dislaimer_content' =>
+ 'Make sure you own the rights for this podcast before importing it.
Copying and broadcasting a podcast without the proper rights is piracy and is liable to prosecution.',
+ 'warning_title' => 'Warning',
+ 'warning_content' =>
+ 'This procedure may take a long time.
The current version does not show any progress while it runs. You will not see anything updated until it is done.
In case of timeout error, increase max_execution_time value.',
'old_podcast_section_title' => 'The podcast to import',
'old_podcast_section_subtitle' => '',
'imported_feed_url' => 'Feed URL',
diff --git a/app/Language/en/PodcastNavigation.php b/app/Language/en/PodcastNavigation.php
index 05fe31f1..3d153206 100644
--- a/app/Language/en/PodcastNavigation.php
+++ b/app/Language/en/PodcastNavigation.php
@@ -20,4 +20,5 @@ return [
'contributor-add' => 'Add contributor',
'settings' => 'Settings',
'platforms' => 'Podcast platforms',
+ 'podcast-analytics' => 'Audiences Overview',
];
diff --git a/app/Models/AnalyticsPodcastsByCountryModel.php b/app/Models/AnalyticsPodcastsByCountryModel.php
index 70f5fc3e..4f209453 100644
--- a/app/Models/AnalyticsPodcastsByCountryModel.php
+++ b/app/Models/AnalyticsPodcastsByCountryModel.php
@@ -2,7 +2,7 @@
/**
* Class AnalyticsPodcastsByCountryModel
- * Model for analytics_episodes_by_country table in database
+ * Model for analytics_podcasts_by_country table in database
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
@@ -14,8 +14,7 @@ use CodeIgniter\Model;
class AnalyticsPodcastsByCountryModel extends Model
{
- protected $table = 'analytics_episodes_by_country';
- protected $primaryKey = 'id';
+ protected $table = 'analytics_podcasts_by_country';
protected $allowedFields = [];
diff --git a/app/Models/AnalyticsPodcastsByEpisodeModel.php b/app/Models/AnalyticsPodcastsByEpisodeModel.php
new file mode 100644
index 00000000..59c82360
--- /dev/null
+++ b/app/Models/AnalyticsPodcastsByEpisodeModel.php
@@ -0,0 +1,113 @@
+select('id, season_number, number, title')
+ ->orderBy('id', 'DESC')
+ ->where(['podcast_id' => $podcastId])
+ ->findAll(5);
+
+ $found = $this->select('age AS X');
+
+ $letter = 97;
+ foreach ($lastEpisodes as $episode) {
+ $found = $found
+ ->selectSum(
+ '(CASE WHEN `episode_id`=' .
+ $episode->id .
+ ' THEN `hits` END)',
+ chr($letter) . 'Y'
+ )
+ ->select(
+ '"' .
+ (empty($episode->season_number)
+ ? ''
+ : $episode->season_number) .
+ (empty($episode->number)
+ ? ''
+ : '-' . $episode->number . '/ ') .
+ $episode->title .
+ '" AS ' .
+ chr($letter) .
+ 'Value'
+ );
+ $letter++;
+ }
+
+ $found = $found
+ ->where([
+ 'podcast_id' => $podcastId,
+ 'age <' => 60,
+ ])
+ ->groupBy('X')
+ ->orderBy('X', 'ASC')
+ ->findAll();
+
+ cache()->save(
+ "{$podcastId}_analytics_podcast_by_episode_by_day",
+ $found,
+ 14400
+ );
+ }
+ return $found;
+ } else {
+ if (
+ !($found = cache(
+ "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day"
+ ))
+ ) {
+ $found = $this->select('date as labels')
+ ->selectSum('hits', 'values')
+ ->where([
+ 'episode_id' => $episodeId,
+ 'podcast_id' => $podcastId,
+ ])
+ ->groupBy('labels')
+ ->orderBy('labels', 'ASC')
+ ->findAll();
+
+ cache()->save(
+ "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day",
+ $found,
+ 14400
+ );
+ }
+ return $found;
+ }
+ }
+}
diff --git a/app/Models/AnalyticsPodcastsByPlayerModel.php b/app/Models/AnalyticsPodcastsByPlayerModel.php
index 5e0ff822..900b44fa 100644
--- a/app/Models/AnalyticsPodcastsByPlayerModel.php
+++ b/app/Models/AnalyticsPodcastsByPlayerModel.php
@@ -15,7 +15,6 @@ use CodeIgniter\Model;
class AnalyticsPodcastsByPlayerModel extends Model
{
protected $table = 'analytics_podcasts_by_player';
- protected $primaryKey = 'id';
protected $allowedFields = [];
@@ -23,4 +22,120 @@ class AnalyticsPodcastsByPlayerModel extends Model
protected $useSoftDeletes = false;
protected $useTimestamps = false;
+
+ /**
+ * Gets all data for a podcast
+ *
+ * @param int $podcastId
+ *
+ * @return array
+ */
+ public function getDataByApp(int $podcastId): array
+ {
+ if (
+ !($found = cache(
+ "{$podcastId}_analytics_podcasts_by_player_by_app"
+ ))
+ ) {
+ $found = $this->select('`app` as `labels`')
+ ->selectSum('`hits`', '`values`')
+ ->where([
+ '`podcast_id`' => $podcastId,
+ '`app` !=' => null,
+ '`bot`' => 0,
+ '`date` >' => date('Y-m-d', strtotime('-1 week')),
+ ])
+ ->groupBy('`labels`')
+ ->orderBy('`values``', 'DESC')
+ ->findAll(10);
+
+ cache()->save(
+ "{$podcastId}_analytics_podcasts_by_player_by_app",
+ $found,
+ 14400
+ );
+ }
+
+ return $found;
+ }
+
+ /**
+ * Gets all data for a podcast
+ *
+ * @param int $podcastId
+ *
+ * @return array
+ */
+ public function getDataByDevice(int $podcastId): array
+ {
+ if (
+ !($found = cache(
+ "{$podcastId}_analytics_podcasts_by_player_by_device"
+ ))
+ ) {
+ $foundApp = $this->select(
+ 'CONCAT_WS("/", `device`, `os`, `app`) as `ids`, `app` as `labels`, CONCAT_WS("/", `device`, `os`) as `parents`'
+ )
+ ->selectSum('`hits`', '`values`')
+ ->where([
+ '`podcast_id`' => $podcastId,
+ '`app` !=' => null,
+ '`bot`' => 0,
+ '`date` >' => date('Y-m-d', strtotime('-1 week')),
+ ])
+ ->groupBy('`ids`')
+ ->orderBy('`values``', 'DESC')
+ ->findAll();
+
+ $foundOs = $this->select(
+ 'CONCAT_WS("/", `device`, `os`) as `ids`, `os` as `labels`, `device` as `parents`'
+ )
+ ->selectSum('`hits`', '`values`')
+ ->where([
+ '`podcast_id`' => $podcastId,
+ '`os` !=' => null,
+ '`bot`' => 0,
+ '`date` >' => date('Y-m-d', strtotime('-1 week')),
+ ])
+ ->groupBy('`ids`')
+ ->orderBy('`values``', 'DESC')
+ ->findAll();
+
+ $foundDevice = $this->select(
+ '`device` as `ids`, `device` as `labels`, "" as `parents`'
+ )
+ ->selectSum('`hits`', '`values`')
+ ->where([
+ '`podcast_id`' => $podcastId,
+ '`device` !=' => null,
+ '`bot`' => 0,
+ '`date` >' => date('Y-m-d', strtotime('-1 week')),
+ ])
+ ->groupBy('`ids`')
+ ->orderBy('`values``', 'DESC')
+ ->findAll();
+
+ $foundBot = $this->select(
+ '"bots" as `ids`, "Bots" as `labels`, "" as `parents`'
+ )
+ ->selectSum('`hits`', '`values`')
+ ->where([
+ '`podcast_id`' => $podcastId,
+ '`bot`' => 1,
+ '`date` >' => date('Y-m-d', strtotime('-1 week')),
+ ])
+ ->groupBy('`ids`')
+ ->orderBy('`values``', 'DESC')
+ ->findAll();
+
+ $found = array_merge($foundApp, $foundOs, $foundDevice, $foundBot);
+ cache()->save(
+ "{$podcastId}_analytics_podcasts_by_player_by_device",
+ $found,
+ 14400
+ );
+ }
+
+ return $found;
+ }
}
diff --git a/app/Models/AnalyticsPodcastsByRegionModel.php b/app/Models/AnalyticsPodcastsByRegionModel.php
new file mode 100644
index 00000000..81ab8537
--- /dev/null
+++ b/app/Models/AnalyticsPodcastsByRegionModel.php
@@ -0,0 +1,25 @@
+select('`date` as `labels`')
+ ->selectSum('`hits`', '`values`')
+ ->where([
+ '`podcast_id`' => $podcastId,
+ '`date` >' => date('Y-m-d', strtotime('-1 year')),
+ ])
+ ->groupBy('`labels`')
+ ->orderBy('`labels``', 'ASC')
+ ->findAll();
+
+ cache()->save(
+ "{$podcastId}_analytics_podcast_by_day",
+ $found,
+ 14400
+ );
+ }
+
+ return $found;
+ }
+}
diff --git a/app/Models/AnalyticsWebsiteByBrowserModel.php b/app/Models/AnalyticsWebsiteByBrowserModel.php
index ceee6b3e..85b4fc92 100644
--- a/app/Models/AnalyticsWebsiteByBrowserModel.php
+++ b/app/Models/AnalyticsWebsiteByBrowserModel.php
@@ -15,7 +15,6 @@ use CodeIgniter\Model;
class AnalyticsWebsiteByBrowserModel extends Model
{
protected $table = 'analytics_website_by_browser';
- protected $primaryKey = 'id';
protected $allowedFields = [];
diff --git a/app/Models/AnalyticsWebsiteByEntryPageModel.php b/app/Models/AnalyticsWebsiteByEntryPageModel.php
new file mode 100644
index 00000000..6e7cfa0c
--- /dev/null
+++ b/app/Models/AnalyticsWebsiteByEntryPageModel.php
@@ -0,0 +1,25 @@
+
+
diff --git a/app/Views/_assets/images/logo-castopod.svg b/app/Views/_assets/images/logo-castopod.svg
index 191b6cc9..0208232a 100644
--- a/app/Views/_assets/images/logo-castopod.svg
+++ b/app/Views/_assets/images/logo-castopod.svg
@@ -1,86 +1,40 @@
-
-
@@ -43,12 +43,12 @@