diff options
Diffstat (limited to 'lib')
-rwxr-xr-x | lib/Travelynx.pm | 170 | ||||
-rw-r--r-- | lib/Travelynx/Command/database.pm | 214 | ||||
-rw-r--r-- | lib/Travelynx/Command/work.pm | 95 | ||||
-rw-r--r-- | lib/Travelynx/Controller/Account.pm | 47 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Api.pm | 2 | ||||
-rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 136 | ||||
-rw-r--r-- | lib/Travelynx/Helper/MOTIS.pm | 158 | ||||
-rw-r--r-- | lib/Travelynx/Model/InTransit.pm | 131 | ||||
-rwxr-xr-x | lib/Travelynx/Model/Journeys.pm | 28 | ||||
-rw-r--r-- | lib/Travelynx/Model/Stations.pm | 96 | ||||
-rw-r--r-- | lib/Travelynx/Model/Users.pm | 9 |
11 files changed, 1058 insertions, 28 deletions
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index af46e5a..0429d5e 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -1,6 +1,7 @@ package Travelynx; # Copyright (C) 2020-2023 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -24,6 +25,7 @@ use Travelynx::Helper::DBDB; use Travelynx::Helper::DBRIS; use Travelynx::Helper::HAFAS; use Travelynx::Helper::IRIS; +use Travelynx::Helper::MOTIS; use Travelynx::Helper::Sendmail; use Travelynx::Helper::Traewelling; use Travelynx::Model::InTransit; @@ -260,6 +262,18 @@ sub startup { ); $self->helper( + motis => sub { + my ($self) = @_; + state $motis = Travelynx::Helper::MOTIS->new( + log => $self->app->log, + cache => $self->app->cache_iris_rt, + user_agent => $self->ua, + version => $self->app->config->{version}, + ); + } + ); + + $self->helper( traewelling => sub { my ($self) = @_; state $trwl = Travelynx::Model::Traewelling->new( pg => $self->pg ); @@ -475,6 +489,9 @@ sub startup { return Mojo::Promise->reject('You are already checked in'); } + if ( $opt{motis} ) { + return $self->_checkin_motis_p(%opt); + } if ( $opt{dbris} ) { return $self->_checkin_dbris_p(%opt); } @@ -557,6 +574,147 @@ sub startup { ); $self->helper( + '_checkin_motis_p' => sub { + my ( $self, %opt ) = @_; + + my $station = $opt{station}; + my $train_id = $opt{train_id}; + my $ts = $opt{ts}; + my $uid = $opt{uid} // $self->current_user->{id}; + my $db = $opt{db} // $self->pg->db; + my $hafas; + + my $promise = Mojo::Promise->new; + + $self->motis->get_trip_p( + service => $opt{motis}, + trip_id => $train_id, + )->then( + sub { + my ($trip) = @_; + my $found_stopover; + + for my $stopover ( $trip->stopovers ) { + if ( $stopover->stop->id eq $station ) { + $found_stopover = $stopover; + + # Lines may serve the same stop several times. + # Keep looking until the scheduled departure + # matches the one passed while checking in. + if ( $ts and $stopover->scheduled_departure->epoch == $ts ) { + last; + } + } + } + + if ( not $found_stopover ) { + $promise->reject("Did not find stopover at '$station' within trip '$train_id'"); + return; + } + + for my $stopover ( $trip->stopovers ) { + $self->stations->add_or_update( + stop => $stopover->stop, + db => $db, + motis => $opt{motis}, + ); + } + + $self->stations->add_or_update( + stop => $found_stopover->stop, + db => $db, + motis => $opt{motis}, + ); + + eval { + $self->in_transit->add( + uid => $uid, + db => $db, + journey => $trip, + stopover => $found_stopover, + data => { trip_id => $train_id }, + backend_id => $self->stations->get_backend_id( + motis => $opt{motis} + ), + ); + }; + + if ($@) { + $self->app->log->error("Checkin($uid): INSERT failed: $@"); + $promise->reject( 'INSERT failed: ' . $@ ); + return; + } + + my $polyline; + if ( $trip->polyline ) { + my @station_list; + my @coordinate_list; + for my $coordinate ( $trip->polyline ) { + if ( $coordinate->{stop} ) { + if ( not defined $coordinate->{stop}->{eva} ) { + die() + } + + push( + @coordinate_list, + [ + $coordinate->{lon}, $coordinate->{lat}, + $coordinate->{stop}->{eva} + ] + ); + + push( @station_list, $coordinate->{stop}->name ); + } + else { + push( @coordinate_list, [ $coordinate->{lon}, $coordinate->{lat} ] ); + } + } + + # equal length → polyline only consists of straight + # lines between stops. that's not helpful. + if ( @station_list == @coordinate_list ) { + $self->log->debug( 'Ignoring polyline for ' + . $trip->route_name + . ' as it only consists of straight lines between stops.' + ); + } + else { + $polyline = { + from_eva => ( $trip->stopovers )[0]->stop->{eva}, + to_eva => ( $trip->stopovers )[-1]->stop->{eva}, + coords => \@coordinate_list, + }; + } + } + + if ($polyline) { + $self->in_transit->set_polyline( + uid => $uid, + db => $db, + polyline => $polyline, + ); + } + + # mustn't be called during a transaction + if ( not $opt{in_transaction} ) { + $self->run_hook( $uid, 'checkin' ); + } + + $promise->resolve($trip); + } + )->catch( + sub { + my ($err) = @_; + $promise->reject($err); + return; + } + )->wait; + + return $promise; + } + ); + + $self->helper( '_checkin_dbris_p' => sub { my ( $self, %opt ) = @_; @@ -966,7 +1124,7 @@ sub startup { return $promise->resolve( 0, 'race condition' ); } - if ( $user->{is_dbris} or $user->{is_hafas} ) { + if ( $user->{is_dbris} or $user->{is_hafas} or $user->{is_motis} ) { return $self->_checkout_journey_p(%opt); } @@ -2052,6 +2210,7 @@ sub startup { is_dbris => $latest->{is_dbris}, is_iris => $latest->{is_iris}, is_hafas => $latest->{is_hafas}, + is_motis => $latest->{is_motis}, journey_id => $latest->{journey_id}, timestamp => $action_time, timestamp_delta => $now->epoch - $action_time->epoch, @@ -2063,6 +2222,7 @@ sub startup { real_departure => epoch_to_dt( $latest->{real_dep_ts} ), dep_ds100 => $latest->{dep_ds100}, dep_eva => $latest->{dep_eva}, + dep_external_id => $latest->{dep_external_id}, dep_name => $latest->{dep_name}, dep_lat => $latest->{dep_lat}, dep_lon => $latest->{dep_lon}, @@ -2071,6 +2231,7 @@ sub startup { real_arrival => epoch_to_dt( $latest->{real_arr_ts} ), arr_ds100 => $latest->{arr_ds100}, arr_eva => $latest->{arr_eva}, + arr_external_id => $latest->{arr_external_id}, arr_name => $latest->{arr_name}, arr_lat => $latest->{arr_lat}, arr_lon => $latest->{arr_lon}, @@ -2106,7 +2267,7 @@ sub startup { my $ret = { deprecated => \0, checkedIn => ( - $status->{checked_in} + $status->{checked_in} or $status->{cancelled} ) ? \1 : \0, comment => $status->{comment}, @@ -2114,6 +2275,7 @@ sub startup { id => $status->{backend_id}, type => $status->{is_dbris} ? 'DBRIS' : $status->{is_hafas} ? 'HAFAS' + : $status->{is_motis} ? 'MOTIS' : 'IRIS-TTS', name => $status->{backend_name}, }, @@ -2538,8 +2700,8 @@ sub startup { color => '#673ab7', opacity => @polylines ? $with_polyline - ? 0.4 - : 0.6 + ? 0.4 + : 0.6 : 0.8, }, { diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index d0bc163..0e87b2a 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -1,6 +1,7 @@ package Travelynx::Command::database; # Copyright (C) 2020-2023 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; @@ -11,6 +12,7 @@ use List::Util qw(); use JSON; use Travel::Status::DE::HAFAS; use Travel::Status::DE::IRIS::Stations; +use Travel::Status::MOTIS; has description => 'Initialize or upgrade database layout'; @@ -2854,6 +2856,173 @@ qq{select distinct checkout_station_id from in_transit where backend_id = 0;} } ); }, + + # v61 -> v62 + # Add MOTIS backend type, add RNV and transitous MOTIS backends + sub { + my ($db) = @_; + $db->query( + qq{ + alter table backends add column motis bool default false; + alter table schema_version add column motis varchar(12); + + create table stations_external_ids ( + eva serial not null primary key, + backend_id smallint not null, + external_id text not null, + + unique (backend_id, external_id), + foreign key (eva, backend_id) references stations (eva, source) + ); + + create view stations_with_external_ids as select + stations.*, stations_external_ids.external_id + from stations + left join stations_external_ids on + stations.eva = stations_external_ids.eva and + stations.source = stations_external_ids.backend_id + ; + + drop view in_transit_str; + drop view journeys_str; + drop view users_with_backend; + drop view follows_in_transit; + + create view in_transit_str as select + user_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, in_transit.backend_id as backend_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + dep_station_external_id.external_id as dep_external_id, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + arr_station_external_id.external_id as arr_external_id, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source + left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and in_transit.backend_id = dep_station_external_id.backend_id + left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and in_transit.backend_id = arr_station_external_id.backend_id + left join backends as backend on in_transit.backend_id = backend.id + ; + create view journeys_str as select + journeys.id as journey_id, user_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, journeys.backend_id as backend_id, + train_type, train_line, train_no, train_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + dep_station_external_id.external_id as dep_external_id, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + arr_station_external_id.external_id as arr_external_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, edited, route, messages, user_data, + dep_platform, arr_platform + from journeys + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and journeys.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and journeys.backend_id = arr_station.source + left join stations_external_ids as dep_station_external_id on checkin_station_id = dep_station_external_id.eva and journeys.backend_id = dep_station_external_id.backend_id + left join stations_external_ids as arr_station_external_id on checkout_station_id = arr_station_external_id.eva and journeys.backend_id = arr_station_external_id.backend_id + left join backends as backend on journeys.backend_id = backend.id + ; + create view users_with_backend as select + users.id as id, users.name as name, status, public_level, + email, password, registered_at, last_seen, + deletion_requested, deletion_notified, use_history, + accept_follows, notifications, profile, backend_id, iris, + hafas, efa, dbris, motis, backend.name as backend_name + from users + left join backends as backend on users.backend_id = backend.id + ; + create view follows_in_transit as select + r1.subject_id as follower_id, user_id as followee_id, + users.name as followee_name, + train_type, train_line, train_no, train_id, + backend.iris as is_iris, backend.hafas as is_hafas, + backend.efa as is_efa, backend.dbris as is_dbris, + backend.motis as is_motis, + backend.name as backend_name, in_transit.backend_id as backend_id, + extract(epoch from checkin_time) as checkin_ts, + extract(epoch from sched_departure) as sched_dep_ts, + extract(epoch from real_departure) as real_dep_ts, + checkin_station_id as dep_eva, + dep_station.ds100 as dep_ds100, + dep_station.name as dep_name, + dep_station.lat as dep_lat, + dep_station.lon as dep_lon, + extract(epoch from checkout_time) as checkout_ts, + extract(epoch from sched_arrival) as sched_arr_ts, + extract(epoch from real_arrival) as real_arr_ts, + checkout_station_id as arr_eva, + arr_station.ds100 as arr_ds100, + arr_station.name as arr_name, + arr_station.lat as arr_lat, + arr_station.lon as arr_lon, + polyline_id, + polylines.polyline as polyline, + visibility, + coalesce(visibility, users.public_level & 127) as effective_visibility, + cancelled, route, messages, user_data, + dep_platform, arr_platform, data + from in_transit + left join polylines on polylines.id = polyline_id + left join users on users.id = user_id + left join relations as r1 on r1.predicate = 1 and r1.object_id = user_id + left join stations as dep_station on checkin_station_id = dep_station.eva and in_transit.backend_id = dep_station.source + left join stations as arr_station on checkout_station_id = arr_station.eva and in_transit.backend_id = arr_station.source + left join backends as backend on in_transit.backend_id = backend.id + order by checkin_time desc + ; + } + ); + $db->query( + qq{ + update schema_version set version = 62; + } + ); + }, ); sub sync_stations { @@ -3044,7 +3213,7 @@ sub sync_stations { } } -sub sync_backends { +sub sync_backends_hafas { my ($db) = @_; for my $service ( Travel::Status::DE::HAFAS::get_services() ) { my $present = $db->select( @@ -3074,6 +3243,36 @@ sub sync_backends { { hafas => $Travel::Status::DE::HAFAS::VERSION } ); } +sub sync_backends_motis { + my ($db) = @_; + for my $service ( Travel::Status::MOTIS::get_services() ) { + my $present = $db->select( + 'backends', + 'count(*) as count', + { + motis => 1, + name => $service->{shortname} + } + )->hash->{count}; + if ( not $present ) { + $db->insert( + 'backends', + { + iris => 0, + hafas => 0, + efa => 0, + dbris => 0, + motis => 1, + name => $service->{shortname}, + }, + { on_conflict => undef } + ); + } + } + + $db->update( 'schema_version', { motis => $Travel::Status::MOTIS::VERSION } ); +} + sub setup_db { my ($db) = @_; my $tx = $db->begin; @@ -3169,7 +3368,18 @@ sub migrate_db { else { say "Synchronizing with Travel::Status::DE::HAFAS $Travel::Status::DE::HAFAS::VERSION"; - sync_backends($db); + sync_backends_hafas($db); + } + + my $motis_version = get_schema_version( $db, 'motis' ) // '0'; + say "Found backend table for Motis v${motis_version}"; + if ( $motis_version eq $Travel::Status::MOTIS::VERSION ) { + say 'Backend table is up-to-date'; + } + else { + say +"Synchronizing with Travel::Status::MOTIS $Travel::Status::MOTIS::VERSION"; + sync_backends_motis($db); } $db->update( 'schema_version', diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm index 4876e98..4ff5c9e 100644 --- a/lib/Travelynx/Command/work.pm +++ b/lib/Travelynx/Command/work.pm @@ -1,6 +1,7 @@ package Travelynx::Command::work; # Copyright (C) 2020-2023 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Command'; @@ -184,6 +185,100 @@ sub run { next; } + if ( $entry->{is_motis} ) { + + eval { + $self->app->motis->trip_id( + service => $entry->{backend_name}, + trip_id => $train_id, + )->then( + sub { + my ($journey) = @_; + + for my $stopover ( $journey->stopovers ) { + if ( not defined $stopover->stop->{eva} ) { + my $stop = $self->app->stations->get_by_external_id( + external_id => $stopover->stop->id, + motis => $entry->{backend_name}, + ); + + $stopover->stop->{eva} = $stop->{eva}; + } + } + + my $found_departure; + my $found_arrival; + for my $stopover ( $journey->stopovers ) { + if ( $stopover->stop->{eva} == $dep ) { + $found_departure = $stopover; + } + + if ( $arr and $stopover->stop->{eva} == $arr ) { + $found_arrival = $stopover; + last; + } + } + + if ( not $found_departure ) { + $self->app->log->debug("Did not find $dep within trip $train_id"); + return; + } + + if ( $found_departure->realtime_departure ) { + $self->app->in_transit->update_departure_motis( + uid => $uid, + journey => $journey, + stopover => $found_departure, + dep_eva => $dep, + arr_eva => $arr, + train_id => $train_id, + ); + } + + if ( $found_arrival and $found_arrival->realtime_arrival ) { + $self->app->in_transit->update_arrival_motis( + uid => $uid, + journey => $journey, + train_id => $train_id, + stopover => $found_arrival, + dep_eva => $dep, + arr_eva => $arr + ); + } + } + )->catch( + sub { + my ($err) = @_; + $self->app->log->error( +"work($uid) @ MOTIS $entry->{backend_name}: journey: $err" + ); + if ( $err =~ m{HTTP 429} ) { + $dbris_rate_limited = 1; + } + } + )->wait; + + if ( $arr + and $entry->{real_arr_ts} + and $now->epoch - $entry->{real_arr_ts} > 600 ) + { + $self->app->checkout_p( + station => $arr, + force => 2, + dep_eva => $dep, + arr_eva => $arr, + uid => $uid + )->wait; + } + }; + if ($@) { + $errors += 1; + $self->app->log->error( + "work($uid) @ MOTIS $entry->{backend_name}: $@"); + } + next; + } + if ( $entry->{is_hafas} ) { eval { diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index e36dcc3..4c69f91 100644 --- a/lib/Travelynx/Controller/Account.pm +++ b/lib/Travelynx/Controller/Account.pm @@ -1,6 +1,7 @@ package Travelynx::Controller::Account; # Copyright (C) 2020-2023 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Controller'; @@ -1137,6 +1138,52 @@ sub backend_form { $type = undef; } } + elsif ( $backend->{motis} ) { + my $s = $self->motis->get_service( $backend->{name} ); + + $type = 'MOTIS'; + $backend->{longname} = $s->{name}; + $backend->{homepage} = $s->{homepage}; + $backend->{regions} = [ map { $place_map{$_} // $_ } + @{ $s->{coverage}{regions} // [] } ]; + $backend->{has_area} = $s->{coverage}{area} ? 1 : 0; + + if ( $backend->{name} eq 'transitous' ) { + $backend->{regions} = [ 'Weltweit' ]; + } + if ( $backend->{name} eq 'RNV' ) { + $backend->{homepage} = 'https://rnv-online.de/'; + } + + if ( + $s->{coverage}{area} + and $s->{coverage}{area}{type} eq 'Polygon' + and $self->lonlat_in_polygon( + $s->{coverage}{area}{coordinates}, + [ $user_lon, $user_lat ] + ) + ) + { + push( @suggested_backends, $backend ); + } + elsif ( $s->{coverage}{area} + and $s->{coverage}{area}{type} eq 'MultiPolygon' ) + { + for my $s_poly ( + @{ $s->{coverage}{area}{coordinates} // [] } ) + { + if ( + $self->lonlat_in_polygon( + $s_poly, [ $user_lon, $user_lat ] + ) + ) + { + push( @suggested_backends, $backend ); + last; + } + } + } + } $backend->{type} = $type; } diff --git a/lib/Travelynx/Controller/Api.pm b/lib/Travelynx/Controller/Api.pm index 9fe72b2..572d3fa 100755 --- a/lib/Travelynx/Controller/Api.pm +++ b/lib/Travelynx/Controller/Api.pm @@ -189,6 +189,7 @@ sub travel_v1 { my $train_id; my $dbris = sanitize( undef, $payload->{dbris} ); my $hafas = sanitize( undef, $payload->{hafas} ); + my $motis = sanitize( undef, $payload->{motis} ); if ( not $hafas and exists $payload->{train}{journeyID} ) { $dbris //= 'bahn.de'; @@ -298,6 +299,7 @@ sub travel_v1 { uid => $uid, hafas => $hafas, dbris => $dbris, + motis => $motis, ); } )->then( diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm index 0d89fb9..aa7ee9b 100755 --- a/lib/Travelynx/Controller/Traveling.pm +++ b/lib/Travelynx/Controller/Traveling.pm @@ -1,6 +1,7 @@ package Travelynx::Controller::Traveling; # Copyright (C) 2020-2023 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later use Mojo::Base 'Mojolicious::Controller'; @@ -48,6 +49,11 @@ sub get_connecting_trains_p { # cases. But not reliably. Probably best to leave it out entirely then. return $promise->reject; } + if ( $user->{backend_motis} ) { + + # FIXME: The following code can't handle external_ids currently + return $promise->reject; + } if ( $opt{eva} ) { if ( $use_history & 0x01 ) { @@ -408,8 +414,8 @@ sub homepage { } } else { - @recent_targets = uniq_by { $_->{eva} } - $self->journeys->get_latest_checkout_stations( uid => $uid ); + @recent_targets = uniq_by { $_->{external_id_or_eva} } + $self->journeys->get_latest_checkout_stations( uid => $uid ); } $self->render( 'landingpage', @@ -550,7 +556,7 @@ sub geolocation { return; } - my ( $dbris_service, $hafas_service ); + my ( $dbris_service, $hafas_service, $motis_service ); my $backend = $self->stations->get_backend( backend_id => $backend_id ); if ( $backend->{dbris} ) { $dbris_service = $backend->{name}; @@ -558,6 +564,9 @@ sub geolocation { elsif ( $backend->{hafas} ) { $hafas_service = $backend->{name}; } + elsif ( $backend->{motis} ) { + $motis_service = $backend->{name}; + } if ($dbris_service) { $self->render_later; @@ -654,6 +663,54 @@ sub geolocation { return; } + elsif ($motis_service) { + $self->render_later; + + Travel::Status::MOTIS->new_p( + promise => 'Mojo::Promise', + user_agent => $self->ua, + + service => $motis_service, + stops_by_coordinate => { + lat => $lat, + lon => $lon + } + )->then( + sub { + my ($motis) = @_; + my @motis = map { + { + id => $_->id, + name => $_->name, + distance => 0, + motis => $motis_service, + } + } $motis->results; + + if ( @motis > 10 ) { + @motis = @motis[ 0 .. 9 ]; + } + + $self->render( + json => { + candidates => [@motis], + } + ); + } + )->catch( + sub { + my ($err) = @_; + $self->render( + json => { + candidates => [], + warning => $err, + } + ); + } + )->wait; + + return; + } my @iris = map { { @@ -734,6 +791,7 @@ sub travel_action { return $self->checkin_p( dbris => $params->{dbris}, hafas => $params->{hafas}, + motis => $params->{motis}, station => $params->{station}, train_id => $params->{train}, train_suffix => $params->{suffix}, @@ -872,6 +930,13 @@ sub travel_action { . '?hafas=' . $status->{backend_name}; } + elsif ( $status->{is_motis} ) { + $redir + = '/s/' + . $status->{dep_external_id} + . '?motis=' + . $status->{backend_name}; + } else { $redir = '/s/' . $status->{dep_ds100}; } @@ -889,6 +954,7 @@ sub travel_action { $self->checkin_p( dbris => $params->{dbris}, hafas => $params->{hafas}, + motis => $params->{motis}, station => $params->{station}, train_id => $params->{train}, ts => $params->{ts}, @@ -1021,6 +1087,8 @@ sub station { // ( $user->{backend_dbris} ? $user->{backend_name} : undef ); my $hafas_service = $self->param('hafas') // ( $user->{backend_hafas} ? $user->{backend_name} : undef ); + my $motis_service = $self->param('motis') + // ( $user->{backend_motis} ? $user->{backend_name} : undef ); my $promise; if ($dbris_service) { if ( $station !~ m{ [@] L = \d+ }x ) { @@ -1053,6 +1121,35 @@ sub station { lookahead => 30, ); } + elsif ($motis_service) { + if ( $station !~ m/.*_.*/ ) { + $self->render_later; + $self->motis->get_station_by_query_p( + service => $motis_service, + query => $station, + )->then( + sub { + my ($motis_station) = @_; + $self->redirect_to( '/s/' . $motis_station->{id} ); + } + )->catch( + sub { + my ($err) = @_; + say "$err"; + + $self->redirect_to('/'); + } + )->wait; + return; + } + $promise = $self->motis->get_departures_p( + service => $motis_service, + station_id => $station, + timestamp => $timestamp, + lookbehind => 30, + lookahead => 30, + ) + } else { $promise = $self->iris->get_departures_p( station => $station, @@ -1106,6 +1203,17 @@ sub station { related_stations => [], }; } + elsif ($motis_service) { + @results = map { $_->[0] } + sort { $b->[1] <=> $a->[1] } + map { [ $_, $_->stopover->departure->epoch ] } $status->results; + + $status = { + station_eva => $station, + station_name => $status->{results}->[0]->stopover->stop->name, + related_stations => [], + }; + } else { # You can't check into a train which terminates here @@ -1172,6 +1280,7 @@ sub station { user => $user, dbris => $dbris_service, hafas => $hafas_service, + motis => $motis_service, eva => $status->{station_eva}, datetime => $timestamp, now_in_range => $now_within_range, @@ -1192,6 +1301,7 @@ sub station { user => $user, dbris => $dbris_service, hafas => $hafas_service, + motis => $motis_service, eva => $status->{station_eva}, datetime => $timestamp, now_in_range => $now_within_range, @@ -1211,6 +1321,7 @@ sub station { user => $user, dbris => $dbris_service, hafas => $hafas_service, + motis => $motis_service, eva => $status->{station_eva}, datetime => $timestamp, now_in_range => $now_within_range, @@ -1322,6 +1433,23 @@ sub redirect_to_station { } )->wait; } + elsif ( $self->param('backend_motis') ) { + $self->render_later; + $self->motis->get_station_by_query( + service => $self->param('backend_motis'), + query => $station, + )->then( + sub { + my ($motis_station) = @_; + $self->redirect_to( '/s/' . $motis_station->{id} ); + } + )->catch( + sub { + my ($err) = @_; + $self->redirect_to('/'); + } + )->wait; + } else { $self->redirect_to("/s/${station}"); } @@ -1892,7 +2020,7 @@ sub journey_details { $delay = sprintf( 'mit %+d ', ( - $journey->{rt_arrival}->epoch + $journey->{rt_arrival}->epoch - $journey->{sched_arrival}->epoch ) / 60 ); diff --git a/lib/Travelynx/Helper/MOTIS.pm b/lib/Travelynx/Helper/MOTIS.pm new file mode 100644 index 0000000..ee2b10b --- /dev/null +++ b/lib/Travelynx/Helper/MOTIS.pm @@ -0,0 +1,158 @@ +package Travelynx::Helper::MOTIS; + +# Copyright (C) 2025 networkException <git@nwex.de> +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; +use utf8; + +use DateTime; +use Encode qw(decode); +use JSON; +use Mojo::Promise; +use Mojo::UserAgent; + +use Travel::Status::MOTIS; + +sub _epoch { + my ($dt) = @_; + + return $dt ? $dt->epoch : 0; +} + +sub new { + my ( $class, %opt ) = @_; + + my $version = $opt{version}; + + $opt{header} + = { 'User-Agent' => +"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx" + }; + + return bless( \%opt, $class ); +} + +sub get_service { + my ( $self, $service ) = @_; + + return Travel::Status::MOTIS::get_service($service); +} + +sub get_station_by_query_p { + my ( $self, %opt ) = @_; + + $opt{service} //= 'transitous'; + + my $promise = Mojo::Promise->new; + + Travel::Status::MOTIS->new_p( + cache => $self->{cache}, + promise => 'Mojo::Promise', + user_agent => Mojo::UserAgent->new, + lwp_options => { + timeout => 10, + agent => $self->{header}{'User-Agent'}, + }, + + service => $opt{service}, + stops_by_query => $opt{query}, + )->then( + sub { + my ($motis) = @_; + my $found; + + for my $result ( $motis->results ) { + if ( defined $result->id ) { + $promise->resolve($result); + return; + } + } + + $promise->reject("Unable to find station '$opt{query}'"); + return; + } + )->catch( + sub { + my ($err) = @_; + $promise->reject("'$err' while trying to look up '$opt{query}'"); + return; + } + )->wait; + + return $promise; +} + +sub get_departures_p { + my ( $self, %opt ) = @_; + + $opt{service} //= 'transitous'; + + my $timestamp = ( + $opt{timestamp} + ? $opt{timestamp}->clone + : DateTime->now + )->subtract( minutes => $opt{lookbehind} ); + + return Travel::Status::MOTIS->new_p( + cache => $self->{cache}, + promise => 'Mojo::Promise', + user_agent => Mojo::UserAgent->new, + lwp_options => { + timeout => 10, + agent => $self->{header}{'User-Agent'}, + }, + + service => $opt{service}, + timestamp => $timestamp, + stop_id => $opt{station_id}, + results => 60, + ); +} + +sub get_trip_p { + my ( $self, %opt ) = @_; + + $opt{service} //= 'transitous'; + + my $promise = Mojo::Promise->new; + + Travel::Status::MOTIS->new_p( + with_polyline => $opt{with_polyline}, + cache => $self->{realtime_cache}, + promise => 'Mojo::Promise', + user_agent => Mojo::UserAgent->new, + + service => $opt{service}, + trip_id => $opt{trip_id}, + )->then( + sub { + my ($motis) = @_; + my $journey = $motis->result; + + if ($journey) { + $self->{log}->debug("get_trip_p($opt{trip_id}): success"); + $promise->resolve($journey); + return; + } + + $self->{log}->debug("get_trip_p($opt{trip_id}): no journey"); + $promise->reject('no journey'); + return; + } + )->catch( + sub { + my ($err) = @_; + $self->{log}->debug("get_trip_p($opt{trip_id}): error $err"); + $promise->reject($err); + return; + } + )->wait; + + return $promise; +} + +1; diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm index eeb1d87..0e3fdc6 100644 --- a/lib/Travelynx/Model/InTransit.pm +++ b/lib/Travelynx/Model/InTransit.pm @@ -1,6 +1,7 @@ package Travelynx::Model::InTransit; # Copyright (C) 2020-2025 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -99,6 +100,7 @@ sub add { my $train_suffix = $opt{train_suffix}; my $journey = $opt{journey}; my $stop = $opt{stop}; + my $stopover = $opt{stopover}; my $checkin_station_id = $opt{departure_eva}; my $route = $opt{route}; my $data = $opt{data}; @@ -282,6 +284,57 @@ sub add { } ); } + elsif ( $journey and $stopover ) { + + # MOTIS + my @route; + for my $journey_stopover ( $journey->stopovers ) { + push( + @route, + [ + $journey_stopover->stop->name, + $journey_stopover->stop->{eva} // die('eva not set for stopover'), + { + sched_arr => _epoch( $journey_stopover->scheduled_arrival ), + sched_dep => _epoch( $journey_stopover->scheduled_departure ), + rt_arr => _epoch( $journey_stopover->realtime_arrival ), + rt_dep => _epoch( $journey_stopover->realtime_departure ), + arr_delay => $journey_stopover->arrival_delay, + dep_delay => $journey_stopover->departure_delay, + lat => $journey_stopover->stop->lat, + lon => $journey_stopover->stop->lon, + } + ] + ); + } + + $db->insert( + 'in_transit', + { + user_id => $uid, + cancelled => $stopover->{is_cancelled} + ? 1 + : 0, + checkin_station_id => $stopover->stop->{eva}, + checkin_time => DateTime->now( time_zone => 'Europe/Berlin' ), + dep_platform => $stopover->track, + train_type => $journey->mode, + train_no => q{}, + train_id => $journey->id, + train_line => $journey->route_name, + sched_departure => $stopover->scheduled_departure, + real_departure => $stopover->departure, + route => $json->encode( \@route ), + data => JSON->new->encode( + { + rt => $stopover->{is_realtime} ? 1 : 0, + %{ $data // {} } + } + ), + backend_id => $backend_id, + } + ); + } else { die('neither train nor journey specified'); } @@ -331,7 +384,7 @@ sub postprocess { # Note that the departure stop may be present more than once in @route, # e.g. when traveling along ring lines such as S41 / S42 in Berlin. if ( - $ret->{dep_name} + $ret->{dep_name} and $station->[0] eq $ret->{dep_name} and not($station->[2]{sched_dep} and $station->[2]{sched_dep} < $ret->{sched_dep_ts} ) @@ -887,6 +940,33 @@ sub update_departure_dbris { ); } +sub update_departure_motis { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $journey = $opt{journey}; + my $stopover = $opt{stopover}; + my $json = JSON->new; + + # selecting on user_id and train_no avoids a race condition if a user checks + # into a new train while we are fetching data for their previous journey. In + # this case, the new train would receive data from the previous journey. + $db->update( + 'in_transit', + { + real_departure => $stopover->{realtime_departure}, + }, + { + user_id => $uid, + train_id => $opt{train_id}, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + ); +} + sub update_departure_hafas { my ( $self, %opt ) = @_; my $uid = $opt{uid}; @@ -1053,6 +1133,55 @@ sub update_arrival_dbris { ); } +sub update_arrival_motis { + my ( $self, %opt ) = @_; + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $dep_eva = $opt{dep_eva}; + my $arr_eva = $opt{arr_eva}; + my $journey = $opt{journey}; + my $stopover = $opt{stopover}; + my $json = JSON->new; + + my @route; + for my $journey_stopover ( $journey->stopovers ) { + push( + @route, + [ + $journey_stopover->stop->name, + $journey_stopover->stop->{eva} // die('eva not set for stopover'), + { + sched_arr => _epoch( $journey_stopover->scheduled_arrival ), + sched_dep => _epoch( $journey_stopover->scheduled_departure ), + rt_arr => _epoch( $journey_stopover->realtime_arrival ), + rt_dep => _epoch( $journey_stopover->realtime_departure ), + arr_delay => $journey_stopover->arrival_delay, + dep_delay => $journey_stopover->departure_delay, + lat => $journey_stopover->stop->lat, + lon => $journey_stopover->stop->lon, + } + ] + ); + } + + # selecting on user_id and train_no avoids a race condition if a user checks + # into a new train while we are fetching data for their previous journey. In + # this case, the new train would receive data from the previous journey. + $db->update( + 'in_transit', + { + real_arrival => $stopover->{realtime_arrival}, + route => $json->encode( [@route] ), + }, + { + user_id => $uid, + train_id => $opt{train_id}, + checkin_station_id => $dep_eva, + checkout_station_id => $arr_eva, + } + ); +} + sub update_arrival_hafas { my ( $self, %opt ) = @_; my $uid = $opt{uid}; diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm index f5bc9f1..fff59f9 100755 --- a/lib/Travelynx/Model/Journeys.pm +++ b/lib/Travelynx/Model/Journeys.pm @@ -549,7 +549,7 @@ sub get { my @select = ( - qw(journey_id is_dbris is_iris is_hafas backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility) + qw(journey_id is_dbris is_iris is_hafas is_motis backend_name backend_id train_type train_line train_no checkin_ts sched_dep_ts real_dep_ts dep_eva dep_ds100 dep_name dep_lat dep_lon checkout_ts sched_arr_ts real_arr_ts arr_eva arr_ds100 arr_name arr_lat arr_lon cancelled edited route messages user_data visibility effective_visibility) ); my %where = ( user_id => $uid, @@ -610,6 +610,7 @@ sub get { is_dbris => $entry->{is_dbris}, is_iris => $entry->{is_iris}, is_hafas => $entry->{is_hafas}, + is_motis => $entry->{is_motis}, backend_name => $entry->{backend_name}, backend_id => $entry->{backend_id}, type => $entry->{train_type}, @@ -871,8 +872,9 @@ sub get_latest_checkout_stations { my $res = $db->select( 'journeys_str', [ - 'arr_name', 'arr_eva', 'train_id', 'backend_id', - 'backend_name', 'is_dbris', 'is_hafas' + 'arr_name', 'arr_eva', 'arr_external_id', 'train_id', + 'backend_id', 'backend_name', 'is_dbris', 'is_hafas', + 'is_motis' ], { user_id => $uid, @@ -894,11 +896,13 @@ sub get_latest_checkout_stations { push( @ret, { - name => $row->{arr_name}, - eva => $row->{arr_eva}, - dbris => $row->{is_dbris} ? $row->{backend_name} : 0, - hafas => $row->{is_hafas} ? $row->{backend_name} : 0, - backend_id => $row->{backend_id}, + name => $row->{arr_name}, + eva => $row->{arr_eva}, + external_id_or_eva => $row->{arr_external_id} // $row->{arr_eva}, + dbris => $row->{is_dbris} ? $row->{backend_name} : 0, + hafas => $row->{is_hafas} ? $row->{backend_name} : 0, + motis => $row->{is_motis} ? $row->{backend_name} : 0, + backend_id => $row->{backend_id}, } ); } @@ -1392,7 +1396,7 @@ sub compute_review { if ( not $most_undelay or $speedup > ( - $most_undelay->{sched_duration} + $most_undelay->{sched_duration} - $most_undelay->{rt_duration} ) ) @@ -1665,7 +1669,7 @@ sub compute_stats { @inconsistencies, { conflict => { - train => $journey->{type} . ' ' + train => ( $journey->{is_motis} ? '' : $journey->{type} ) . ' ' . ( $journey->{line} // $journey->{no} ), arr => epoch_to_dt( $journey->{rt_arr_ts} ) ->strftime('%d.%m.%Y %H:%M'), @@ -1691,7 +1695,7 @@ sub compute_stats { $next_departure = $journey->{rt_dep_ts}; $next_id = $journey->{id}; $next_train - = $journey->{type} . ' ' . ( $journey->{line} // $journey->{no} ),; + = ( $journey->{is_motis} ? '' : $journey->{type} ) . ' ' . ( $journey->{line} // $journey->{no} ),; } my $ret = { km_route => $km_route, @@ -1740,7 +1744,7 @@ sub get_stats { # checks out of a train or manually edits/adds a journey. if ( - not $opt{write_only} + not $opt{write_only} and not $opt{review} and my $stats = $self->stats_cache->get( uid => $uid, diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm index 3d6549f..761c5de 100644 --- a/lib/Travelynx/Model/Stations.pm +++ b/lib/Travelynx/Model/Stations.pm @@ -1,6 +1,7 @@ package Travelynx::Model::Stations; # Copyright (C) 2022 Birte Kristina Friesel +# Copyright (C) 2025 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -28,6 +29,9 @@ sub get_backend_id { if ( $opt{dbris} and $self->{backend_id}{dbris}{ $opt{dbris} } ) { return $self->{backend_id}{dbris}{ $opt{dbris} }; } + if ( $opt{motis} and $self->{backend_id}{motis}{ $opt{motis} } ) { + return $self->{backend_id}{motis}{ $opt{motis} }; + } my $db = $opt{db} // $self->{pg}->db; my $backend_id = 0; @@ -54,6 +58,17 @@ sub get_backend_id { )->hash->{id}; $self->{backend_id}{hafas}{ $opt{hafas} } = $backend_id; } + elsif ( $opt{motis} ) { + $backend_id = $db->select( + 'backends', + ['id'], + { + motis => 1, + name => $opt{motis} + } + )->hash->{id}; + $self->{backend_id}{motis}{ $opt{motis} } = $backend_id; + } return $backend_id; } @@ -85,7 +100,7 @@ sub get_backends { $opt{db} //= $self->{pg}->db; my $res = $opt{db} - ->select( 'backends', [ 'id', 'name', 'iris', 'hafas', 'dbris' ] ); + ->select( 'backends', [ 'id', 'name', 'iris', 'hafas', 'dbris', 'motis' ] ); my @ret; while ( my $row = $res->hash ) { @@ -97,6 +112,7 @@ sub get_backends { iris => $row->{iris}, dbris => $row->{dbris}, hafas => $row->{hafas}, + motis => $row->{motis}, } ); } @@ -149,6 +165,61 @@ sub add_or_update { return; } + if ( $opt{motis} ) { + if ( + my $s = $self->get_by_external_id( + external_id => $stop->id, + db => $opt{db}, + backend_id => $opt{backend_id} + ) + ) + { + $opt{db}->update( + 'stations', + { + name => $stop->name, + lat => $stop->lat, + lon => $stop->lon, + archived => 0 + }, + { + eva => $s->{eva}, + source => $opt{backend_id} + } + ); + + $stop->{eva} = $s->{eva}; + + return; + } + + my $s = $opt{db}->query( + qq { + with new_station as ( + insert into stations_external_ids (backend_id, external_id) + values (?, ?) + returning eva, backend_id + ) + + insert into stations (eva, name, lat, lon, source, archived) + values ((select eva from new_station), ?, ?, ?, (select backend_id from new_station), ?) + returning * + }, + ( + $opt{backend_id}, + $stop->id, + $stop->name, + $stop->lat, + $stop->lon, + 0, + ) + ); + + $stop->{eva} = $s->hash->{eva}; + + return; + } + my $loc = $stop->loc; if ( my $s = $self->get_by_eva( @@ -184,6 +255,8 @@ sub add_or_update { archived => 0 } ); + + return; } sub add_meta { @@ -276,6 +349,27 @@ sub get_by_eva { )->hash; } +# Slow +sub get_by_external_id { + my ( $self, %opt ) = @_; + + if ( not $opt{external_id} ) { + return; + } + + $opt{db} //= $self->{pg}->db; + $opt{backend_id} //= $self->get_backend_id(%opt); + + return $opt{db}->select( + 'stations_with_external_ids', + '*', + { + external_id => $opt{external_id}, + source => $opt{backend_id}, + } + )->hash; +} + # Fast sub get_by_evas { my ( $self, %opt ) = @_; diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm index 750e889..10ab17e 100644 --- a/lib/Travelynx/Model/Users.pm +++ b/lib/Travelynx/Model/Users.pm @@ -418,7 +418,7 @@ sub get { . 'extract(epoch from registered_at) as registered_at_ts, ' . 'extract(epoch from last_seen) as last_seen_ts, ' . 'extract(epoch from deletion_requested) as deletion_requested_ts, ' - . 'backend_id, backend_name, hafas, dbris', + . 'backend_id, backend_name, hafas, dbris, motis', { id => $uid } )->hash; if ($user) { @@ -459,6 +459,7 @@ sub get { backend_name => $user->{backend_name}, backend_dbris => $user->{dbris}, backend_hafas => $user->{hafas}, + backend_motis => $user->{motis}, }; } return undef; @@ -1026,11 +1027,11 @@ sub get_followers { id => $row->{id}, name => $row->{name}, following_back => ( - $row->{inverse_predicate} + $row->{inverse_predicate} and $row->{inverse_predicate} == $predicate_atoi{follows} ) ? 1 : 0, followback_requested => ( - $row->{inverse_predicate} + $row->{inverse_predicate} and $row->{inverse_predicate} == $predicate_atoi{requests_follow} ) ? 1 : 0, @@ -1102,7 +1103,7 @@ sub get_followees { id => $row->{id}, name => $row->{name}, following_back => ( - $row->{inverse_predicate} + $row->{inverse_predicate} and $row->{inverse_predicate} == $predicate_atoi{follows} ) ? 1 : 0, } |