mirror of
https://code.castopod.org/adaures/castopod
synced 2025-06-05 08:52:00 +00:00
refactor(analytics): move all analytics files to a new Libraries/Analytics folder
- add page hit on podcast activity page - update development docs
This commit is contained in:
parent
1c0d6cee44
commit
247ae1824f
@ -20,9 +20,9 @@ RUN docker-php-ext-configure gd --with-jpeg-dir=/usr/include/ \
|
|||||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||||
|
|
||||||
RUN echo "file_uploads = On\n" \
|
RUN echo "file_uploads = On\n" \
|
||||||
"memory_limit = 100M\n" \
|
"memory_limit = 512M\n" \
|
||||||
"upload_max_filesize = 100M\n" \
|
"upload_max_filesize = 500M\n" \
|
||||||
"post_max_size = 120M\n" \
|
"post_max_size = 512M\n" \
|
||||||
"max_execution_time = 300\n" \
|
"max_execution_time = 300\n" \
|
||||||
> /usr/local/etc/php/conf.d/uploads.ini
|
> /usr/local/etc/php/conf.d/uploads.ini
|
||||||
|
|
||||||
|
35
app/Config/Analytics.php
Normal file
35
app/Config/Analytics.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use Analytics\Config\Analytics as AnalyticsBase;
|
||||||
|
|
||||||
|
class Analytics extends AnalyticsBase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
* Route filters options
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
public $routeFilters = [
|
||||||
|
'analytics-full-data' => 'permission:podcasts-view,podcast-view',
|
||||||
|
'analytics-data' => 'permission:podcasts-view,podcast-view',
|
||||||
|
'analytics-filtered-data' => 'permission:podcasts-view,podcast-view',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
// set the analytics gateway behind the admin gateway.
|
||||||
|
// Only logged in users should be able to view analytics
|
||||||
|
$this->gateway = config('App')->adminGateway . '/analytics';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnclosureUrl($enclosureUri)
|
||||||
|
{
|
||||||
|
helper('media');
|
||||||
|
|
||||||
|
return media_base_url($enclosureUri);
|
||||||
|
}
|
||||||
|
}
|
@ -43,6 +43,7 @@ class Autoload extends AutoloadConfig
|
|||||||
APP_NAMESPACE => APPPATH, // For custom app namespace
|
APP_NAMESPACE => APPPATH, // For custom app namespace
|
||||||
'Config' => APPPATH . 'Config',
|
'Config' => APPPATH . 'Config',
|
||||||
'ActivityPub' => APPPATH . 'Libraries/ActivityPub',
|
'ActivityPub' => APPPATH . 'Libraries/ActivityPub',
|
||||||
|
'Analytics' => APPPATH . 'Libraries/Analytics',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -70,18 +70,6 @@ $routes->group(config('App')->installGateway, function ($routes) {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route for podcast audio file analytics (/audio/pack(podcast_id,episode_id,bytes_threshold,filesize,duration,date)/podcast_folder/filename.mp3)
|
|
||||||
$routes->head('audio/(:base64)/(:any)', 'Analytics::hit/$1/$2', [
|
|
||||||
'as' => 'analytics_hit',
|
|
||||||
]);
|
|
||||||
$routes->get('audio/(:base64)/(:any)', 'Analytics::hit/$1/$2', [
|
|
||||||
'as' => 'analytics_hit',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Show the Unknown UserAgents
|
|
||||||
$routes->get('.well-known/unknown-useragents', 'UnknownUserAgents');
|
|
||||||
$routes->get('.well-known/unknown-useragents/(:num)', 'UnknownUserAgents/$1');
|
|
||||||
|
|
||||||
$routes->get('.well-known/platforms', 'Platform');
|
$routes->get('.well-known/platforms', 'Platform');
|
||||||
|
|
||||||
// Admin area
|
// Admin area
|
||||||
@ -237,31 +225,6 @@ $routes->group(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->get(
|
|
||||||
'analytics-data/(:segment)',
|
|
||||||
'AnalyticsData::getData/$1/$2',
|
|
||||||
[
|
|
||||||
'as' => 'analytics-full-data',
|
|
||||||
'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
|
// Podcast episodes
|
||||||
$routes->group('episodes', function ($routes) {
|
$routes->group('episodes', function ($routes) {
|
||||||
$routes->get('/', 'Episode::list/$1', [
|
$routes->get('/', 'Episode::list/$1', [
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @copyright 2020 Podlibre
|
|
||||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
|
||||||
* @link https://castopod.org/
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace App\Controllers\Admin;
|
|
||||||
|
|
||||||
use App\Models\PodcastModel;
|
|
||||||
use App\Models\EpisodeModel;
|
|
||||||
|
|
||||||
class AnalyticsData extends BaseController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var \App\Entities\Podcast|null
|
|
||||||
*/
|
|
||||||
protected $podcast;
|
|
||||||
protected $className;
|
|
||||||
protected $methodName;
|
|
||||||
protected $episode;
|
|
||||||
|
|
||||||
public function _remap($method, ...$params)
|
|
||||||
{
|
|
||||||
if (count($params) > 1) {
|
|
||||||
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' . (empty($params[2]) ? '' : $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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -44,8 +44,6 @@ class Episode extends BaseController
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$episodeModel = new EpisodeModel();
|
|
||||||
|
|
||||||
self::triggerWebpageHit($this->podcast->id);
|
self::triggerWebpageHit($this->podcast->id);
|
||||||
|
|
||||||
$locale = service('request')->getLocale();
|
$locale = service('request')->getLocale();
|
||||||
@ -65,7 +63,7 @@ class Episode extends BaseController
|
|||||||
'persons' => $podcastPersons,
|
'persons' => $podcastPersons,
|
||||||
];
|
];
|
||||||
|
|
||||||
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
|
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
|
||||||
$this->podcast->id,
|
$this->podcast->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -112,7 +110,6 @@ class Episode extends BaseController
|
|||||||
$cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_embeddable_player_{$theme}_{$locale}";
|
$cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_embeddable_player_{$theme}_{$locale}";
|
||||||
|
|
||||||
if (!($cachedView = cache($cacheName))) {
|
if (!($cachedView = cache($cacheName))) {
|
||||||
$episodeModel = new EpisodeModel();
|
|
||||||
$theme = EpisodeModel::$themes[$theme];
|
$theme = EpisodeModel::$themes[$theme];
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@ -121,7 +118,7 @@ class Episode extends BaseController
|
|||||||
'theme' => $theme,
|
'theme' => $theme,
|
||||||
];
|
];
|
||||||
|
|
||||||
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
|
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
|
||||||
$this->podcast->id,
|
$this->podcast->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -111,7 +111,7 @@ class Install extends Controller
|
|||||||
// show database config view to fix value
|
// show database config view to fix value
|
||||||
session()->setFlashdata(
|
session()->setFlashdata(
|
||||||
'error',
|
'error',
|
||||||
lang('Install.messages.databaseConnectError')
|
lang('Install.messages.databaseConnectError'),
|
||||||
);
|
);
|
||||||
|
|
||||||
return view('install/database_config');
|
return view('install/database_config');
|
||||||
@ -159,7 +159,7 @@ class Install extends Controller
|
|||||||
return redirect()
|
return redirect()
|
||||||
->to(
|
->to(
|
||||||
(empty(host_url()) ? config('App')->baseURL : host_url()) .
|
(empty(host_url()) ? config('App')->baseURL : host_url()) .
|
||||||
config('App')->installGateway
|
config('App')->installGateway,
|
||||||
)
|
)
|
||||||
->withInput()
|
->withInput()
|
||||||
->with('errors', $this->validator->getErrors());
|
->with('errors', $this->validator->getErrors());
|
||||||
@ -181,8 +181,8 @@ class Install extends Controller
|
|||||||
// redirect to full install url with new baseUrl input
|
// redirect to full install url with new baseUrl input
|
||||||
return redirect(0)->to(
|
return redirect(0)->to(
|
||||||
reduce_double_slashes(
|
reduce_double_slashes(
|
||||||
$baseUrl . '/' . config('App')->installGateway
|
$baseUrl . '/' . config('App')->installGateway,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,14 +209,14 @@ class Install extends Controller
|
|||||||
|
|
||||||
self::writeEnv([
|
self::writeEnv([
|
||||||
'database.default.hostname' => $this->request->getPost(
|
'database.default.hostname' => $this->request->getPost(
|
||||||
'db_hostname'
|
'db_hostname',
|
||||||
),
|
),
|
||||||
'database.default.database' => $this->request->getPost('db_name'),
|
'database.default.database' => $this->request->getPost('db_name'),
|
||||||
'database.default.username' => $this->request->getPost(
|
'database.default.username' => $this->request->getPost(
|
||||||
'db_username'
|
'db_username',
|
||||||
),
|
),
|
||||||
'database.default.password' => $this->request->getPost(
|
'database.default.password' => $this->request->getPost(
|
||||||
'db_password'
|
'db_password',
|
||||||
),
|
),
|
||||||
'database.default.DBPrefix' => $this->request->getPost('db_prefix'),
|
'database.default.DBPrefix' => $this->request->getPost('db_prefix'),
|
||||||
]);
|
]);
|
||||||
@ -258,6 +258,7 @@ class Install extends Controller
|
|||||||
|
|
||||||
!$migrations->setNamespace('Myth\Auth')->latest();
|
!$migrations->setNamespace('Myth\Auth')->latest();
|
||||||
!$migrations->setNamespace('ActivityPub')->latest();
|
!$migrations->setNamespace('ActivityPub')->latest();
|
||||||
|
!$migrations->setNamespace('Analytics')->latest();
|
||||||
!$migrations->setNamespace(APP_NAMESPACE)->latest();
|
!$migrations->setNamespace(APP_NAMESPACE)->latest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,7 +297,7 @@ class Install extends Controller
|
|||||||
[
|
[
|
||||||
'email' => 'required|valid_email|is_unique[users.email]',
|
'email' => 'required|valid_email|is_unique[users.email]',
|
||||||
'password' => 'required|strong_password',
|
'password' => 'required|strong_password',
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$this->validate($rules)) {
|
if (!$this->validate($rules)) {
|
||||||
|
@ -37,6 +37,8 @@ class Podcast extends BaseController
|
|||||||
|
|
||||||
public function activity()
|
public function activity()
|
||||||
{
|
{
|
||||||
|
self::triggerWebpageHit($this->podcast->id);
|
||||||
|
|
||||||
helper('persons');
|
helper('persons');
|
||||||
$persons = [];
|
$persons = [];
|
||||||
construct_person_array($this->podcast->persons, $persons);
|
construct_person_array($this->podcast->persons, $persons);
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Seeds;
|
namespace App\Database\Seeds;
|
||||||
|
|
||||||
use App\Models\PodcastModel;
|
use App\Models\PodcastModel;
|
||||||
use App\Models\EpisodeModel;
|
use App\Models\EpisodeModel;
|
||||||
|
|
||||||
@ -23,16 +24,16 @@ class FakePodcastsAnalyticsSeeder extends Seeder
|
|||||||
|
|
||||||
$jsonUserAgents = json_decode(
|
$jsonUserAgents = json_decode(
|
||||||
file_get_contents(
|
file_get_contents(
|
||||||
'https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json'
|
'https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json',
|
||||||
),
|
),
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
$jsonRSSUserAgents = json_decode(
|
$jsonRSSUserAgents = json_decode(
|
||||||
file_get_contents(
|
file_get_contents(
|
||||||
'https://raw.githubusercontent.com/opawg/podcast-rss-useragents/master/src/rss-ua.json'
|
'https://raw.githubusercontent.com/opawg/podcast-rss-useragents/master/src/rss-ua.json',
|
||||||
),
|
),
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($podcast) {
|
if ($podcast) {
|
||||||
@ -60,7 +61,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder
|
|||||||
->findAll();
|
->findAll();
|
||||||
foreach ($episodes as $episode) {
|
foreach ($episodes as $episode) {
|
||||||
$age = floor(
|
$age = floor(
|
||||||
($date - strtotime($episode->published_at)) / 86400
|
($date - strtotime($episode->published_at)) / 86400,
|
||||||
);
|
);
|
||||||
$proba1 = floor(exp(3 - $age / 40)) + 1;
|
$proba1 = floor(exp(3 - $age / 40)) + 1;
|
||||||
|
|
||||||
@ -97,7 +98,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder
|
|||||||
|
|
||||||
$cityReader = new \GeoIp2\Database\Reader(
|
$cityReader = new \GeoIp2\Database\Reader(
|
||||||
WRITEPATH .
|
WRITEPATH .
|
||||||
'uploads/GeoLite2-City/GeoLite2-City.mmdb'
|
'uploads/GeoLite2-City/GeoLite2-City.mmdb',
|
||||||
);
|
);
|
||||||
|
|
||||||
$countryCode = 'N/A';
|
$countryCode = 'N/A';
|
||||||
@ -196,7 +197,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder
|
|||||||
->insertBatch($analytics_podcasts_by_region);
|
->insertBatch($analytics_podcasts_by_region);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
echo "Create one podcast and some episodes first.\n";
|
echo "COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Seeds;
|
namespace App\Database\Seeds;
|
||||||
|
|
||||||
use App\Models\PodcastModel;
|
use App\Models\PodcastModel;
|
||||||
use App\Models\EpisodeModel;
|
use App\Models\EpisodeModel;
|
||||||
|
|
||||||
@ -193,7 +194,7 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
|
|||||||
->findAll();
|
->findAll();
|
||||||
foreach ($episodes as $episode) {
|
foreach ($episodes as $episode) {
|
||||||
$age = floor(
|
$age = floor(
|
||||||
($date - strtotime($episode->published_at)) / 86400
|
($date - strtotime($episode->published_at)) / 86400,
|
||||||
);
|
);
|
||||||
$proba1 = floor(exp(3 - $age / 40)) + 1;
|
$proba1 = floor(exp(3 - $age / 40)) + 1;
|
||||||
|
|
||||||
@ -254,7 +255,7 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
|
|||||||
->insertBatch($website_by_referer);
|
->insertBatch($website_by_referer);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
echo "Create one podcast and some episodes first.\n";
|
echo "COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -336,37 +336,14 @@ class Episode extends Entity
|
|||||||
{
|
{
|
||||||
helper('analytics');
|
helper('analytics');
|
||||||
|
|
||||||
return base_url(
|
return generate_episode_analytics_url(
|
||||||
route_to(
|
$this->podcast_id,
|
||||||
'analytics_hit',
|
$this->id,
|
||||||
base64_url_encode(
|
$this->enclosure_uri,
|
||||||
pack(
|
$this->enclosure_duration,
|
||||||
'I*',
|
$this->enclosure_filesize,
|
||||||
$this->attributes['podcast_id'],
|
$this->enclosure_headersize,
|
||||||
$this->attributes['id'],
|
$this->published_at,
|
||||||
// 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_duration'],
|
|
||||||
strtotime($this->attributes['published_at']),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
$this->attributes['enclosure_uri'],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -520,9 +497,9 @@ class Episode extends Entity
|
|||||||
empty($this->getPodcast()->partner_image_url)
|
empty($this->getPodcast()->partner_image_url)
|
||||||
? ''
|
? ''
|
||||||
: "<div><a href=\"{$this->getPartnerLink(
|
: "<div><a href=\"{$this->getPartnerLink(
|
||||||
$serviceSlug
|
$serviceSlug,
|
||||||
)}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImage(
|
)}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImage(
|
||||||
$serviceSlug
|
$serviceSlug,
|
||||||
)}\" alt=\"Partner image\" /></a></div>") .
|
)}\" alt=\"Partner image\" /></a></div>") .
|
||||||
$this->attributes['description_html'] .
|
$this->attributes['description_html'] .
|
||||||
(empty($this->getPodcast()->episode_description_footer_html)
|
(empty($this->getPodcast()->episode_description_footer_html)
|
||||||
|
@ -1,357 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @copyright 2020 Podlibre
|
|
||||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
|
||||||
* @link https://castopod.org/
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode Base64 for URLs
|
|
||||||
*/
|
|
||||||
function base64_url_encode($input)
|
|
||||||
{
|
|
||||||
return strtr(base64_encode($input), '+/=', '._-');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode Base64 from URL
|
|
||||||
*/
|
|
||||||
function base64_url_decode($input)
|
|
||||||
{
|
|
||||||
return base64_decode(strtr($input, '._-', '+/='));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user country in session variable, for analytics purpose
|
|
||||||
*/
|
|
||||||
function set_user_session_deny_list_ip()
|
|
||||||
{
|
|
||||||
$session = \Config\Services::session();
|
|
||||||
$session->start();
|
|
||||||
|
|
||||||
if (!$session->has('denyListIp')) {
|
|
||||||
$session->set(
|
|
||||||
'denyListIp',
|
|
||||||
\Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
$cityReader = new \GeoIp2\Database\Reader(
|
|
||||||
WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb',
|
|
||||||
);
|
|
||||||
$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('location', $location);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user player in session variable, for analytics purpose
|
|
||||||
*/
|
|
||||||
function set_user_session_player()
|
|
||||||
{
|
|
||||||
$session = \Config\Services::session();
|
|
||||||
$session->start();
|
|
||||||
|
|
||||||
if (!$session->has('player')) {
|
|
||||||
$playerFound = null;
|
|
||||||
$userAgent = $_SERVER['HTTP_USER_AGENT'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$playerFound = \Opawg\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 ($playerFound) {
|
|
||||||
$session->set('player', $playerFound);
|
|
||||||
} else {
|
|
||||||
$session->set('player', [
|
|
||||||
'app' => '- unknown -',
|
|
||||||
'device' => '',
|
|
||||||
'os' => '',
|
|
||||||
'bot' => 0,
|
|
||||||
]);
|
|
||||||
// Add to unknown list
|
|
||||||
try {
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$procedureNameAnalyticsUnknownUseragents = $db->prefixTable(
|
|
||||||
'analytics_unknown_useragents',
|
|
||||||
);
|
|
||||||
$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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user browser in session variable, for analytics purpose
|
|
||||||
*/
|
|
||||||
function set_user_session_browser()
|
|
||||||
{
|
|
||||||
$session = \Config\Services::session();
|
|
||||||
$session->start();
|
|
||||||
|
|
||||||
if (!$session->has('browser')) {
|
|
||||||
$browserName = '- Other -';
|
|
||||||
try {
|
|
||||||
$whichbrowser = new \WhichBrowser\Parser(getallheaders());
|
|
||||||
$browserName = $whichbrowser->browser->name;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$browserName = '- Could not get browser name -';
|
|
||||||
}
|
|
||||||
if ($browserName == null) {
|
|
||||||
$browserName = '- Could not get browser name -';
|
|
||||||
}
|
|
||||||
$session->set('browser', $browserName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user referer in session variable, for analytics purpose
|
|
||||||
*/
|
|
||||||
function set_user_session_referer()
|
|
||||||
{
|
|
||||||
$session = \Config\Services::session();
|
|
||||||
$session->start();
|
|
||||||
|
|
||||||
$newreferer = isset($_SERVER['HTTP_REFERER'])
|
|
||||||
? $_SERVER['HTTP_REFERER']
|
|
||||||
: '- Direct -';
|
|
||||||
$newreferer =
|
|
||||||
parse_url($newreferer, PHP_URL_HOST) ==
|
|
||||||
parse_url(current_url(false), PHP_URL_HOST)
|
|
||||||
? '- Direct -'
|
|
||||||
: $newreferer;
|
|
||||||
if (!$session->has('referer') or $newreferer != '- Direct -') {
|
|
||||||
$session->set('referer', $newreferer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
|
|
||||||
if (!$session->get('denyListIp')) {
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
|
|
||||||
$referer = $session->get('referer');
|
|
||||||
$domain = empty(parse_url($referer, PHP_URL_HOST))
|
|
||||||
? '- Direct -'
|
|
||||||
: 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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* ✅ RSS User-agent https://github.com/opawg/podcast-rss-useragents
|
|
||||||
* ✅ 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
|
|
||||||
* @param string $serviceName The name of the service that had fetched the RSS feed
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
function podcast_hit(
|
|
||||||
$podcastId,
|
|
||||||
$episodeId,
|
|
||||||
$bytesThreshold,
|
|
||||||
$fileSize,
|
|
||||||
$duration,
|
|
||||||
$publicationDate,
|
|
||||||
$serviceName
|
|
||||||
) {
|
|
||||||
$session = \Config\Services::session();
|
|
||||||
$session->start();
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
//We get the HTTP header field `Range`:
|
|
||||||
$httpRange = isset($_SERVER['HTTP_RANGE'])
|
|
||||||
? $_SERVER['HTTP_RANGE']
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// We create a sha1 hash for this IP_Address+User_Agent+Episode_ID (used to count only once multiple episode downloads):
|
|
||||||
$episodeHashId =
|
|
||||||
'_IpUaEp_' .
|
|
||||||
sha1(
|
|
||||||
$_SERVER['REMOTE_ADDR'] .
|
|
||||||
'_' .
|
|
||||||
$_SERVER['HTTP_USER_AGENT'] .
|
|
||||||
'_' .
|
|
||||||
$episodeId,
|
|
||||||
);
|
|
||||||
// Was this episode downloaded in the past 24h:
|
|
||||||
$downloadedBytes = cache($episodeHashId);
|
|
||||||
// Rolling window is 24 hours (86400 seconds):
|
|
||||||
$rollingTTL = 86400;
|
|
||||||
if ($downloadedBytes) {
|
|
||||||
// In case it was already downloaded, TTL should be adjusted (rolling window is 24h since 1st download):
|
|
||||||
$rollingTTL =
|
|
||||||
cache()->getMetadata($episodeHashId)['expire'] - time();
|
|
||||||
} else {
|
|
||||||
// If it was never downloaded that means that zero byte were downloaded:
|
|
||||||
$downloadedBytes = 0;
|
|
||||||
}
|
|
||||||
// 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 (!$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($episodeHashId, $downloadedBytes, $rollingTTL);
|
|
||||||
|
|
||||||
// If more that 1mn was downloaded, that's a hit, we send that to the database:
|
|
||||||
if ($downloadedBytes >= $bytesThreshold) {
|
|
||||||
$db = \Config\Database::connect();
|
|
||||||
$procedureName = $db->prefixTable('analytics_podcasts');
|
|
||||||
|
|
||||||
$age = intdiv(time() - $publicationDate, 86400);
|
|
||||||
|
|
||||||
// We create a sha1 hash for this IP_Address+User_Agent+Podcast_ID (used to count unique listeners):
|
|
||||||
$listenerHashId =
|
|
||||||
'_IpUaPo_' .
|
|
||||||
sha1(
|
|
||||||
$_SERVER['REMOTE_ADDR'] .
|
|
||||||
'_' .
|
|
||||||
$_SERVER['HTTP_USER_AGENT'] .
|
|
||||||
'_' .
|
|
||||||
$podcastId,
|
|
||||||
);
|
|
||||||
$newListener = 1;
|
|
||||||
// Has this listener already downloaded an episode today:
|
|
||||||
$downloadsByUser = cache($listenerHashId);
|
|
||||||
// We add one download
|
|
||||||
if ($downloadsByUser) {
|
|
||||||
$newListener = 0;
|
|
||||||
$downloadsByUser++;
|
|
||||||
} else {
|
|
||||||
$downloadsByUser = 1;
|
|
||||||
}
|
|
||||||
// Listener count is calculated from 00h00 to 23h59:
|
|
||||||
$midnightTTL = strtotime('tomorrow') - time();
|
|
||||||
// We save the download count for this user until midnight:
|
|
||||||
cache()->save($listenerHashId, $downloadsByUser, $midnightTTL);
|
|
||||||
|
|
||||||
$db->query(
|
|
||||||
"CALL $procedureName(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);",
|
|
||||||
[
|
|
||||||
$podcastId,
|
|
||||||
$episodeId,
|
|
||||||
$session->get('location')['countryCode'],
|
|
||||||
$session->get('location')['regionCode'],
|
|
||||||
$session->get('location')['latitude'],
|
|
||||||
$session->get('location')['longitude'],
|
|
||||||
$serviceName,
|
|
||||||
$session->get('player')['app'],
|
|
||||||
$session->get('player')['device'],
|
|
||||||
$session->get('player')['os'],
|
|
||||||
$session->get('player')['bot'],
|
|
||||||
$fileSize,
|
|
||||||
$duration,
|
|
||||||
$age,
|
|
||||||
$newListener,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// If things go wrong the show must go on and the user must be able to download the file
|
|
||||||
log_message('critical', $e);
|
|
||||||
}
|
|
||||||
}
|
|
@ -88,7 +88,7 @@ if (!function_exists('extract_params_from_episode_uri')) {
|
|||||||
preg_match(
|
preg_match(
|
||||||
'/@(?P<podcastName>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,191})/',
|
'/@(?P<podcastName>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,191})/',
|
||||||
$episodeUri->getPath(),
|
$episodeUri->getPath(),
|
||||||
$matches
|
$matches,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
38
app/Libraries/Analytics/Config/Analytics.php
Normal file
38
app/Libraries/Analytics/Config/Analytics.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Analytics\Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Config\BaseConfig;
|
||||||
|
|
||||||
|
class Analytics extends BaseConfig
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Gateway to analytic routes.
|
||||||
|
* By default, all analytics routes will be under `/analytics` path
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $gateway = 'analytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
* Route filters options
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
public $routeFilters = [
|
||||||
|
'analytics-full-data' => '',
|
||||||
|
'analytics-data' => '',
|
||||||
|
'analytics-filtered-data' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the full enclosure url
|
||||||
|
*
|
||||||
|
* @param string $filename
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getEnclosureUrl(string $enclosureUri)
|
||||||
|
{
|
||||||
|
return base_url($enclosureUri);
|
||||||
|
}
|
||||||
|
}
|
74
app/Libraries/Analytics/Config/Routes.php
Normal file
74
app/Libraries/Analytics/Config/Routes.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2021 Podlibre
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analytics routes file
|
||||||
|
*/
|
||||||
|
|
||||||
|
$routes->addPlaceholder(
|
||||||
|
'class',
|
||||||
|
'\bPodcastByCountry|\bPodcastByEpisode|\bPodcastByHour|\bPodcastByPlayer|\bPodcastByRegion|\bPodcastByService|\bPodcast|\bWebsiteByBrowser|\bWebsiteByEntryPage|\bWebsiteByReferer',
|
||||||
|
);
|
||||||
|
$routes->addPlaceholder(
|
||||||
|
'filter',
|
||||||
|
'\bWeekly|\bYearly|\bByDay|\bByWeekday|\bByMonth|\bByAppWeekly|\bByAppYearly|\bByOsWeekly|\bByDeviceWeekly|\bBots|\bByServiceWeekly|\bBandwidthByDay|\bUniqueListenersByDay|\bUniqueListenersByMonth|\bTotalListeningTimeByDay|\bTotalListeningTimeByMonth|\bByDomainWeekly|\bByDomainYearly',
|
||||||
|
);
|
||||||
|
|
||||||
|
$routes->group('', ['namespace' => 'Analytics\Controllers'], function (
|
||||||
|
$routes
|
||||||
|
) {
|
||||||
|
$routes->group(config('Analytics')->gateway . '/(:num)/(:class)', function (
|
||||||
|
$routes
|
||||||
|
) {
|
||||||
|
$routes->get('/', 'AnalyticsController::getData/$1/$2', [
|
||||||
|
'as' => 'analytics-full-data',
|
||||||
|
'filter' => config('Analytics')->routeFilters[
|
||||||
|
'analytics-full-data'
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routes->get('(:filter)', 'AnalyticsController::getData/$1/$2/$3', [
|
||||||
|
'as' => 'analytics-data',
|
||||||
|
'filter' => config('Analytics')->routeFilters['analytics-data'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routes->get(
|
||||||
|
'(:filter)/(:num)',
|
||||||
|
'AnalyticsController::getData/$1/$2/$3/$4',
|
||||||
|
[
|
||||||
|
'as' => 'analytics-filtered-data',
|
||||||
|
'filter' => config('Analytics')->routeFilters[
|
||||||
|
'analytics-filtered-data'
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route for podcast audio file analytics (/audio/pack(podcast_id,episode_id,bytes_threshold,filesize,duration,date)/podcast_folder/filename.mp3)
|
||||||
|
$routes->head(
|
||||||
|
'audio/(:base64)/(:any)',
|
||||||
|
'EpisodeAnalyticsController::hit/$1/$2',
|
||||||
|
[
|
||||||
|
'as' => 'episode-analytics-hit',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$routes->get(
|
||||||
|
'audio/(:base64)/(:any)',
|
||||||
|
'EpisodeAnalyticsController::hit/$1/$2',
|
||||||
|
[
|
||||||
|
'as' => 'episode-analytics-hit',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the Unknown UserAgents
|
||||||
|
$routes->get('.well-known/unknown-useragents', 'UnknownUserAgentsController');
|
||||||
|
$routes->get(
|
||||||
|
'.well-known/unknown-useragents/(:num)',
|
||||||
|
'UnknownUserAgentsController/$1',
|
||||||
|
);
|
54
app/Libraries/Analytics/Controllers/AnalyticsController.php
Normal file
54
app/Libraries/Analytics/Controllers/AnalyticsController.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2020 Podlibre
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Analytics\Controllers;
|
||||||
|
|
||||||
|
use CodeIgniter\Controller;
|
||||||
|
|
||||||
|
class AnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $className;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $methodName;
|
||||||
|
|
||||||
|
public function _remap($method, ...$params)
|
||||||
|
{
|
||||||
|
if (!isset($params[1])) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->className = model('Analytics' . $params[1] . 'Model');
|
||||||
|
$this->methodName = 'getData' . (empty($params[2]) ? '' : $params[2]);
|
||||||
|
|
||||||
|
return $this->$method(
|
||||||
|
$params[0],
|
||||||
|
isset($params[3]) ? $params[3] : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getData($podcastId, $episodeId)
|
||||||
|
{
|
||||||
|
$analytics_model = new $this->className();
|
||||||
|
$methodName = $this->methodName;
|
||||||
|
if ($episodeId) {
|
||||||
|
return $this->response->setJSON(
|
||||||
|
$analytics_model->$methodName($podcastId, $episodeId),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return $this->response->setJSON(
|
||||||
|
$analytics_model->$methodName($podcastId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,18 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class Analytics
|
|
||||||
* Creates Analytics controller
|
|
||||||
* @copyright 2020 Podlibre
|
* @copyright 2020 Podlibre
|
||||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace Analytics\Controllers;
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
|
|
||||||
class Analytics extends Controller
|
class EpisodeAnalyticsController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* An array of helpers to be loaded automatically upon
|
* An array of helpers to be loaded automatically upon
|
||||||
@ -23,6 +21,10 @@ class Analytics extends Controller
|
|||||||
*/
|
*/
|
||||||
protected $helpers = ['analytics'];
|
protected $helpers = ['analytics'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Analytics\Config\Analytics
|
||||||
|
*/
|
||||||
|
protected $config;
|
||||||
/**
|
/**
|
||||||
* Constructor.
|
* Constructor.
|
||||||
*/
|
*/
|
||||||
@ -43,12 +45,13 @@ class Analytics extends Controller
|
|||||||
set_user_session_deny_list_ip();
|
set_user_session_deny_list_ip();
|
||||||
set_user_session_location();
|
set_user_session_location();
|
||||||
set_user_session_player();
|
set_user_session_player();
|
||||||
|
|
||||||
|
$this->config = config('Analytics');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add one hit to this episode:
|
// Add one hit to this episode:
|
||||||
public function hit($base64EpisodeData, ...$filename)
|
public function hit($base64EpisodeData, ...$enclosureUri)
|
||||||
{
|
{
|
||||||
helper('media', 'analytics');
|
|
||||||
$session = \Config\Services::session();
|
$session = \Config\Services::session();
|
||||||
$session->start();
|
$session->start();
|
||||||
$serviceName = '';
|
$serviceName = '';
|
||||||
@ -62,7 +65,7 @@ class Analytics extends Controller
|
|||||||
|
|
||||||
$episodeData = unpack(
|
$episodeData = unpack(
|
||||||
'IpodcastId/IepisodeId/IbytesThreshold/IfileSize/Iduration/IpublicationDate',
|
'IpodcastId/IepisodeId/IbytesThreshold/IfileSize/Iduration/IpublicationDate',
|
||||||
base64_url_decode($base64EpisodeData)
|
base64_url_decode($base64EpisodeData),
|
||||||
);
|
);
|
||||||
|
|
||||||
podcast_hit(
|
podcast_hit(
|
||||||
@ -72,8 +75,9 @@ class Analytics extends Controller
|
|||||||
$episodeData['fileSize'],
|
$episodeData['fileSize'],
|
||||||
$episodeData['duration'],
|
$episodeData['duration'],
|
||||||
$episodeData['publicationDate'],
|
$episodeData['publicationDate'],
|
||||||
$serviceName
|
$serviceName,
|
||||||
);
|
);
|
||||||
return redirect()->to(media_base_url($filename));
|
|
||||||
|
return redirect()->to($this->config->getEnclosureUrl($enclosureUri));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -6,15 +6,15 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace Analytics\Controllers;
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
|
|
||||||
class UnknownUserAgents extends Controller
|
class UnknownUserAgentsController extends Controller
|
||||||
{
|
{
|
||||||
public function index($lastKnownId = 0)
|
public function index($lastKnownId = 0)
|
||||||
{
|
{
|
||||||
$model = new \App\Models\UnknownUserAgentsModel();
|
$model = model('UnknownUserAgentsModel');
|
||||||
|
|
||||||
return $this->response->setJSON($model->getUserAgents($lastKnownId));
|
return $this->response->setJSON($model->getUserAgents($lastKnownId));
|
||||||
}
|
}
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
@ -38,10 +38,10 @@ class AddAnalyticsUnknownUseragents extends Migration
|
|||||||
$this->forge->addPrimaryKey('id');
|
$this->forge->addPrimaryKey('id');
|
||||||
// `created_at` and `updated_at` are created with SQL because Model class won’t be used for insertion (Procedure will be used instead)
|
// `created_at` and `updated_at` are created with SQL because Model class won’t be used for insertion (Procedure will be used instead)
|
||||||
$this->forge->addField(
|
$this->forge->addField(
|
||||||
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
|
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()',
|
||||||
);
|
);
|
||||||
$this->forge->addField(
|
$this->forge->addField(
|
||||||
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
|
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()',
|
||||||
);
|
);
|
||||||
$this->forge->createTable('analytics_unknown_useragents');
|
$this->forge->createTable('analytics_unknown_useragents');
|
||||||
}
|
}
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -9,7 +9,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Database\Migrations;
|
namespace Analytics\Database\Migrations;
|
||||||
|
|
||||||
use CodeIgniter\Database\Migration;
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ class AnalyticsPodcastsByService extends Entity
|
|||||||
public function getLabels()
|
public function getLabels()
|
||||||
{
|
{
|
||||||
return \Opawg\UserAgentsPhp\UserAgentsRSS::getName(
|
return \Opawg\UserAgentsPhp\UserAgentsRSS::getName(
|
||||||
$this->attributes['labels']
|
$this->attributes['labels'],
|
||||||
) ?? $this->attributes['labels'];
|
) ?? $this->attributes['labels'];
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Entities;
|
namespace Analytics\Entities;
|
||||||
|
|
||||||
use CodeIgniter\Entity;
|
use CodeIgniter\Entity;
|
||||||
|
|
451
app/Libraries/Analytics/Helpers/analytics_helper.php
Normal file
451
app/Libraries/Analytics/Helpers/analytics_helper.php
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2020 Podlibre
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
use CodeIgniter\Router\Exceptions\RouterException;
|
||||||
|
|
||||||
|
if (!function_exists('base64_url_encode')) {
|
||||||
|
/**
|
||||||
|
* Encode Base64 for URLs
|
||||||
|
*/
|
||||||
|
function base64_url_encode($input)
|
||||||
|
{
|
||||||
|
return strtr(base64_encode($input), '+/=', '._-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('base64_url_decode')) {
|
||||||
|
/**
|
||||||
|
* Decode Base64 from URL
|
||||||
|
*/
|
||||||
|
function base64_url_decode($input)
|
||||||
|
{
|
||||||
|
return base64_decode(strtr($input, '._-', '+/='));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('generate_episode_analytics_url')) {
|
||||||
|
/**
|
||||||
|
* Builds the episode analytics url that redirects to the enclosure url
|
||||||
|
* after analytics hit.
|
||||||
|
*
|
||||||
|
* @param int $podcastId
|
||||||
|
* @param int $episodeId
|
||||||
|
* @param string $enclosureUri
|
||||||
|
* @param int $enclosureDuration
|
||||||
|
* @param int $enclosureFilesize
|
||||||
|
* @param int $enclosureHeadersize
|
||||||
|
* @param \CodeIgniter\I18n\Time $publicationDate
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
* @throws RouterException
|
||||||
|
*/
|
||||||
|
function generate_episode_analytics_url(
|
||||||
|
$podcastId,
|
||||||
|
$episodeId,
|
||||||
|
$enclosureUri,
|
||||||
|
$enclosureDuration,
|
||||||
|
$enclosureFilesize,
|
||||||
|
$enclosureHeadersize,
|
||||||
|
$publicationDate
|
||||||
|
) {
|
||||||
|
return url_to(
|
||||||
|
'episode-analytics-hit',
|
||||||
|
base64_url_encode(
|
||||||
|
pack(
|
||||||
|
'I*',
|
||||||
|
$podcastId,
|
||||||
|
$episodeId,
|
||||||
|
// 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
|
||||||
|
$enclosureDuration <= 60
|
||||||
|
? $enclosureFilesize
|
||||||
|
: $enclosureHeadersize +
|
||||||
|
floor(
|
||||||
|
(($enclosureFilesize - $enclosureHeadersize) /
|
||||||
|
$enclosureDuration) *
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
$enclosureFilesize,
|
||||||
|
$enclosureDuration,
|
||||||
|
strtotime($publicationDate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$enclosureUri,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('set_user_session_deny_list_ip')) {
|
||||||
|
/**
|
||||||
|
* Set user country in session variable, for analytic purposes
|
||||||
|
*/
|
||||||
|
function set_user_session_deny_list_ip()
|
||||||
|
{
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
if (!$session->has('denyListIp')) {
|
||||||
|
$session->set(
|
||||||
|
'denyListIp',
|
||||||
|
\Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('set_user_session_location')) {
|
||||||
|
/**
|
||||||
|
* Set user country in session variable, for analytic purposes
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
$cityReader = new \GeoIp2\Database\Reader(
|
||||||
|
WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb',
|
||||||
|
);
|
||||||
|
$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('location', $location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('set_user_session_player')) {
|
||||||
|
/**
|
||||||
|
* Set user player in session variable, for analytic purposes
|
||||||
|
*/
|
||||||
|
function set_user_session_player()
|
||||||
|
{
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
if (!$session->has('player')) {
|
||||||
|
$playerFound = null;
|
||||||
|
$userAgent = $_SERVER['HTTP_USER_AGENT'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$playerFound = \Opawg\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 ($playerFound) {
|
||||||
|
$session->set('player', $playerFound);
|
||||||
|
} else {
|
||||||
|
$session->set('player', [
|
||||||
|
'app' => '- unknown -',
|
||||||
|
'device' => '',
|
||||||
|
'os' => '',
|
||||||
|
'bot' => 0,
|
||||||
|
]);
|
||||||
|
// Add to unknown list
|
||||||
|
try {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$procedureNameAnalyticsUnknownUseragents = $db->prefixTable(
|
||||||
|
'analytics_unknown_useragents',
|
||||||
|
);
|
||||||
|
$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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('set_user_session_browser')) {
|
||||||
|
/**
|
||||||
|
* Set user browser in session variable, for analytic purposes
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function set_user_session_browser()
|
||||||
|
{
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
if (!$session->has('browser')) {
|
||||||
|
$browserName = '- Other -';
|
||||||
|
try {
|
||||||
|
$whichbrowser = new \WhichBrowser\Parser(getallheaders());
|
||||||
|
$browserName = $whichbrowser->browser->name;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$browserName = '- Could not get browser name -';
|
||||||
|
}
|
||||||
|
if ($browserName == null) {
|
||||||
|
$browserName = '- Could not get browser name -';
|
||||||
|
}
|
||||||
|
$session->set('browser', $browserName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('set_user_session_referer')) {
|
||||||
|
/**
|
||||||
|
* Set user referer in session variable, for analytic purposes
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function set_user_session_referer()
|
||||||
|
{
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
$newreferer = isset($_SERVER['HTTP_REFERER'])
|
||||||
|
? $_SERVER['HTTP_REFERER']
|
||||||
|
: '- Direct -';
|
||||||
|
$newreferer =
|
||||||
|
parse_url($newreferer, PHP_URL_HOST) ==
|
||||||
|
parse_url(current_url(false), PHP_URL_HOST)
|
||||||
|
? '- Direct -'
|
||||||
|
: $newreferer;
|
||||||
|
if (!$session->has('referer') or $newreferer != '- Direct -') {
|
||||||
|
$session->set('referer', $newreferer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('set_user_session_entry_page')) {
|
||||||
|
/**
|
||||||
|
* Set user entry page in session variable, for analytic purposes
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function set_user_session_entry_page()
|
||||||
|
{
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
$entryPage = $_SERVER['REQUEST_URI'];
|
||||||
|
if (!$session->has('entryPage')) {
|
||||||
|
$session->set('entryPage', $entryPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('webpage_hit')) {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param integer $podcastId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function webpage_hit($podcastId)
|
||||||
|
{
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
if (!$session->get('denyListIp')) {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
$referer = $session->get('referer');
|
||||||
|
$domain = empty(parse_url($referer, PHP_URL_HOST))
|
||||||
|
? '- Direct -'
|
||||||
|
: 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(?,?,?,?,?,?)", [
|
||||||
|
$podcastId,
|
||||||
|
$session->get('browser'),
|
||||||
|
$session->get('entryPage'),
|
||||||
|
$referer,
|
||||||
|
$domain,
|
||||||
|
$keywords,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('podcast_hit')) {
|
||||||
|
/**
|
||||||
|
* Counting podcast episode downloads for analytic 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
|
||||||
|
* ✅ RSS User-agent https://github.com/opawg/podcast-rss-useragents
|
||||||
|
* ✅ 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 integer $podcastId The podcast ID
|
||||||
|
* @param integer $episodeId The Episode ID
|
||||||
|
* @param integer $bytesThreshold The minimum total number of bytes that must be downloaded so that an episode is counted (>1mn)
|
||||||
|
* @param integer $fileSize The podcast complete file size
|
||||||
|
* @param string $serviceName The name of the service that had fetched the RSS feed
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function podcast_hit(
|
||||||
|
$podcastId,
|
||||||
|
$episodeId,
|
||||||
|
$bytesThreshold,
|
||||||
|
$fileSize,
|
||||||
|
$duration,
|
||||||
|
$publicationDate,
|
||||||
|
$serviceName
|
||||||
|
) {
|
||||||
|
$session = \Config\Services::session();
|
||||||
|
$session->start();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
//We get the HTTP header field `Range`:
|
||||||
|
$httpRange = isset($_SERVER['HTTP_RANGE'])
|
||||||
|
? $_SERVER['HTTP_RANGE']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// We create a sha1 hash for this IP_Address+User_Agent+Episode_ID (used to count only once multiple episode downloads):
|
||||||
|
$episodeHashId =
|
||||||
|
'_IpUaEp_' .
|
||||||
|
sha1(
|
||||||
|
$_SERVER['REMOTE_ADDR'] .
|
||||||
|
'_' .
|
||||||
|
$_SERVER['HTTP_USER_AGENT'] .
|
||||||
|
'_' .
|
||||||
|
$episodeId,
|
||||||
|
);
|
||||||
|
// Was this episode downloaded in the past 24h:
|
||||||
|
$downloadedBytes = cache($episodeHashId);
|
||||||
|
// Rolling window is 24 hours (86400 seconds):
|
||||||
|
$rollingTTL = 86400;
|
||||||
|
if ($downloadedBytes) {
|
||||||
|
// In case it was already downloaded, TTL should be adjusted (rolling window is 24h since 1st download):
|
||||||
|
$rollingTTL =
|
||||||
|
cache()->getMetadata($episodeHashId)['expire'] - time();
|
||||||
|
} else {
|
||||||
|
// If it was never downloaded that means that zero byte were downloaded:
|
||||||
|
$downloadedBytes = 0;
|
||||||
|
}
|
||||||
|
// 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 (!$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($episodeHashId, $downloadedBytes, $rollingTTL);
|
||||||
|
|
||||||
|
// If more that 1mn was downloaded, that's a hit, we send that to the database:
|
||||||
|
if ($downloadedBytes >= $bytesThreshold) {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$procedureName = $db->prefixTable('analytics_podcasts');
|
||||||
|
|
||||||
|
$age = intdiv(time() - $publicationDate, 86400);
|
||||||
|
|
||||||
|
// We create a sha1 hash for this IP_Address+User_Agent+Podcast_ID (used to count unique listeners):
|
||||||
|
$listenerHashId =
|
||||||
|
'_IpUaPo_' .
|
||||||
|
sha1(
|
||||||
|
$_SERVER['REMOTE_ADDR'] .
|
||||||
|
'_' .
|
||||||
|
$_SERVER['HTTP_USER_AGENT'] .
|
||||||
|
'_' .
|
||||||
|
$podcastId,
|
||||||
|
);
|
||||||
|
$newListener = 1;
|
||||||
|
// Has this listener already downloaded an episode today:
|
||||||
|
$downloadsByUser = cache($listenerHashId);
|
||||||
|
// We add one download
|
||||||
|
if ($downloadsByUser) {
|
||||||
|
$newListener = 0;
|
||||||
|
$downloadsByUser++;
|
||||||
|
} else {
|
||||||
|
$downloadsByUser = 1;
|
||||||
|
}
|
||||||
|
// Listener count is calculated from 00h00 to 23h59:
|
||||||
|
$midnightTTL = strtotime('tomorrow') - time();
|
||||||
|
// We save the download count for this user until midnight:
|
||||||
|
cache()->save(
|
||||||
|
$listenerHashId,
|
||||||
|
$downloadsByUser,
|
||||||
|
$midnightTTL,
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CALL $procedureName(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);",
|
||||||
|
[
|
||||||
|
$podcastId,
|
||||||
|
$episodeId,
|
||||||
|
$session->get('location')['countryCode'],
|
||||||
|
$session->get('location')['regionCode'],
|
||||||
|
$session->get('location')['latitude'],
|
||||||
|
$session->get('location')['longitude'],
|
||||||
|
$serviceName,
|
||||||
|
$session->get('player')['app'],
|
||||||
|
$session->get('player')['device'],
|
||||||
|
$session->get('player')['os'],
|
||||||
|
$session->get('player')['bot'],
|
||||||
|
$fileSize,
|
||||||
|
$duration,
|
||||||
|
$age,
|
||||||
|
$newListener,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// If things go wrong the show must go on and the user must be able to download the file
|
||||||
|
log_message('critical', $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsPodcastByCountryModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcastsByCountry::class;
|
protected $returnType = \Analytics\Entities\AnalyticsPodcastsByCountry::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class AnalyticsPodcastByEpisodeModel
|
||||||
|
* Model for analytics_podcasts_by_episodes table in database
|
||||||
|
* @copyright 2020 Podlibre
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Analytics\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AnalyticsPodcastByEpisodeModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'analytics_podcasts_by_episode';
|
||||||
|
|
||||||
|
protected $allowedFields = [];
|
||||||
|
|
||||||
|
protected $returnType = \Analytics\Entities\AnalyticsPodcastsByEpisode::class;
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $podcastId
|
||||||
|
* @param int $episodeId
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getDataByDay(int $podcastId, int $episodeId): array
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
'age <' => 60,
|
||||||
|
])
|
||||||
|
->groupBy('labels')
|
||||||
|
->orderBy('labels', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
cache()->save(
|
||||||
|
"{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day",
|
||||||
|
$found,
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $podcastId
|
||||||
|
* @param int $episodeId
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getDataByMonth(int $podcastId, int $episodeId = null): array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!($found = cache(
|
||||||
|
"{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month",
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
$found = $this->select('DATE_FORMAT(date,"%Y-%m-01") 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_month",
|
||||||
|
$found,
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsPodcastByHourModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcastsByHour::class;
|
protected $returnType = \Analytics\Entities\AnalyticsPodcastsByHour::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsPodcastByPlayerModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcastsByPlayer::class;
|
protected $returnType = \Analytics\Entities\AnalyticsPodcastsByPlayer::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsPodcastByRegionModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcastsByRegion::class;
|
protected $returnType = \Analytics\Entities\AnalyticsPodcastsByRegion::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsPodcastByServiceModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcastsByService::class;
|
protected $returnType = \Analytics\Entities\AnalyticsPodcastsByService::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsPodcastModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcasts::class;
|
protected $returnType = \Analytics\Entities\AnalyticsPodcasts::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ class AnalyticsUnknownUseragentsModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsUnknownUseragents::class;
|
protected $returnType = \Analytics\Entities\AnalyticsUnknownUseragents::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsWebsiteByBrowserModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsWebsiteByBrowser::class;
|
protected $returnType = \Analytics\Entities\AnalyticsWebsiteByBrowser::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsWebsiteByEntryPageModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsWebsiteByEntryPage::class;
|
protected $returnType = \Analytics\Entities\AnalyticsWebsiteByEntryPage::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ class AnalyticsWebsiteByRefererModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [];
|
protected $allowedFields = [];
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsWebsiteByReferer::class;
|
protected $returnType = \Analytics\Entities\AnalyticsWebsiteByReferer::class;
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
@ -8,7 +8,7 @@
|
|||||||
* @link https://castopod.org/
|
* @link https://castopod.org/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace App\Models;
|
namespace Analytics\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
@ -1,145 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class AnalyticsPodcastByEpisodeModel
|
|
||||||
* Model for analytics_podcasts_by_episodes table in database
|
|
||||||
* @copyright 2020 Podlibre
|
|
||||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
|
||||||
* @link https://castopod.org/
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
|
||||||
|
|
||||||
class AnalyticsPodcastByEpisodeModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'analytics_podcasts_by_episode';
|
|
||||||
|
|
||||||
protected $allowedFields = [];
|
|
||||||
|
|
||||||
protected $returnType = \App\Entities\AnalyticsPodcastsByEpisode::class;
|
|
||||||
protected $useSoftDeletes = false;
|
|
||||||
|
|
||||||
protected $useTimestamps = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int $podcastId, $episodeId
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function getDataByDay(int $podcastId, int $episodeId = null): array
|
|
||||||
{
|
|
||||||
if (!$episodeId) {
|
|
||||||
if (
|
|
||||||
!($found = cache(
|
|
||||||
"{$podcastId}_analytics_podcast_by_episode_by_day",
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
$lastEpisodes = (new EpisodeModel())
|
|
||||||
->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,
|
|
||||||
600,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
'age <' => 60,
|
|
||||||
])
|
|
||||||
->groupBy('labels')
|
|
||||||
->orderBy('labels', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
cache()->save(
|
|
||||||
"{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day",
|
|
||||||
$found,
|
|
||||||
600,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return $found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int $podcastId, $episodeId
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function getDataByMonth(int $podcastId, int $episodeId = null): array
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
!($found = cache(
|
|
||||||
"{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month",
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
$found = $this->select('DATE_FORMAT(date,"%Y-%m-01") 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_month",
|
|
||||||
$found,
|
|
||||||
600,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return $found;
|
|
||||||
}
|
|
||||||
}
|
|
@ -15,7 +15,7 @@
|
|||||||
'analytics-data',
|
'analytics-data',
|
||||||
$podcast->id,
|
$podcast->id,
|
||||||
'Podcast',
|
'Podcast',
|
||||||
'ByDay'
|
'ByDay',
|
||||||
) ?>"></div>
|
) ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -25,7 +25,7 @@
|
|||||||
'analytics-data',
|
'analytics-data',
|
||||||
$podcast->id,
|
$podcast->id,
|
||||||
'Podcast',
|
'Podcast',
|
||||||
'ByMonth'
|
'ByMonth',
|
||||||
) ?>"></div>
|
) ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -35,17 +35,7 @@
|
|||||||
'analytics-data',
|
'analytics-data',
|
||||||
$podcast->id,
|
$podcast->id,
|
||||||
'Podcast',
|
'Podcast',
|
||||||
'BandwidthByDay'
|
'BandwidthByDay',
|
||||||
) ?>"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-12 text-center">
|
|
||||||
<h2><?= lang('Charts.episodes_by_day') ?></h2>
|
|
||||||
<div class="chart-xy" id="by-age-graph" data-chart-type="xy-series-chart" data-chart-url="<?= route_to(
|
|
||||||
'analytics-data',
|
|
||||||
$podcast->id,
|
|
||||||
'PodcastByEpisode',
|
|
||||||
'ByDay'
|
|
||||||
) ?>"></div>
|
) ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -95,9 +95,9 @@ Go to project's root folder and run:
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# See all running processes (you should see 3 processes running)
|
# See all running processes (you should see 3 processes running)
|
||||||
docker ps
|
docker-compose ps
|
||||||
|
|
||||||
# Alternatively, you can check all processes (you should see composer with an Exited status)
|
# Alternatively, you can check all docker processes (you should see composer and npm with an Exited status)
|
||||||
docker ps -a
|
docker ps -a
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -146,17 +146,20 @@ docker-compose run --rm app php spark db:seed LanguageSeeder
|
|||||||
docker-compose run --rm app php spark db:seed PlatformSeeder
|
docker-compose run --rm app php spark db:seed PlatformSeeder
|
||||||
# Populates all Authentication data (roles definition…)
|
# Populates all Authentication data (roles definition…)
|
||||||
docker-compose run --rm app php spark db:seed AuthSeeder
|
docker-compose run --rm app php spark db:seed AuthSeeder
|
||||||
# Populates test data (login: admin / password: AGUehL3P)
|
|
||||||
docker-compose run --rm app php spark db:seed TestSeeder
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. (optionnal) Populate the database with test data:
|
3. (optionnal) Populate the database with test data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Populates test data (login: admin / password: AGUehL3P)
|
||||||
docker-compose run --rm app php spark db:seed TestSeeder
|
docker-compose run --rm app php spark db:seed TestSeeder
|
||||||
|
# Populates with fake podcast analytics
|
||||||
|
docker-compose run --rm app php spark db:seed FakePodcastsAnalyticsSeeder
|
||||||
|
# Populates with fake website analytics
|
||||||
|
docker-compose run --rm app php spark db:seed FakeWebsiteAnalyticsSeeder
|
||||||
```
|
```
|
||||||
|
|
||||||
This will add an active superadmin user with the following credentials:
|
TestSeeder will add an active superadmin user with the following credentials:
|
||||||
|
|
||||||
- username: **admin**
|
- username: **admin**
|
||||||
- password: **AGUehL3P**
|
- password: **AGUehL3P**
|
||||||
@ -205,8 +208,8 @@ To see your changes, go to:
|
|||||||
- [localhost:8080](http://localhost:8080/) for the castopod app
|
- [localhost:8080](http://localhost:8080/) for the castopod app
|
||||||
- [localhost:8888](http://localhost:8888/) for the phpmyadmin interface:
|
- [localhost:8888](http://localhost:8888/) for the phpmyadmin interface:
|
||||||
|
|
||||||
- **Username**: podlibre
|
- username: **podlibre**
|
||||||
- **Password**: castopod
|
- password: **castopod**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -216,19 +219,22 @@ To see your changes, go to:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# monitor the app container
|
# monitor the app container
|
||||||
docker logs --tail 50 --follow --timestamps castopod_app
|
docker-compose logs --tail 50 --follow --timestamps app
|
||||||
|
|
||||||
# monitor the mariadb container
|
# monitor the mariadb container
|
||||||
docker logs --tail 50 --follow --timestamps castopod_mariadb
|
docker-compose logs --tail 50 --follow --timestamps mariadb
|
||||||
|
|
||||||
# monitor the phpmyadmin container
|
# monitor the phpmyadmin container
|
||||||
docker logs --tail 50 --follow --timestamps castopod_phpmyadmin
|
docker-compose logs --tail 50 --follow --timestamps phpmyadmin
|
||||||
|
|
||||||
# restart docker containers
|
# restart docker containers
|
||||||
docker-compose restart
|
docker-compose restart
|
||||||
|
|
||||||
# Destroy all containers, opposite of `up` command
|
# Destroy all containers, opposite of `up` command
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
||||||
|
# Rebuild app container
|
||||||
|
docker-compose build app
|
||||||
```
|
```
|
||||||
|
|
||||||
Check [docker](https://docs.docker.com/engine/reference/commandline/docker/) and
|
Check [docker](https://docs.docker.com/engine/reference/commandline/docker/) and
|
||||||
|
Loading…
x
Reference in New Issue
Block a user