models.py 19.5 KB
Newer Older
1
#
David Trattnig's avatar
David Trattnig committed
2
# Aura Engine (https://gitlab.servus.at/aura/engine)
3
#
David Trattnig's avatar
David Trattnig committed
4
# Copyright (C) 2017-2020 - The Aura Engine Team.
5
#
David Trattnig's avatar
David Trattnig committed
6
7
8
9
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
10
#
David Trattnig's avatar
David Trattnig committed
11
12
13
14
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
15
#
David Trattnig's avatar
David Trattnig committed
16
17
18
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

19

20
import sys
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
21
import time
22
23
import logging
import datetime
24

25
26
import sqlalchemy as sa

David Trattnig's avatar
David Trattnig committed
27
28
29
30
31
32
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, String, ForeignKey, ColumnDefault
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.hybrid import hybrid_property
33

David Trattnig's avatar
David Trattnig committed
34
35
from src.base.config import AuraConfig
from src.base.utils import SimpleUtil
David Trattnig's avatar
David Trattnig committed
36
from src.resources import ResourceUtil
David Trattnig's avatar
David Trattnig committed
37

David Trattnig's avatar
David Trattnig committed
38
# Initialize DB Model and session
David Trattnig's avatar
David Trattnig committed
39
config = AuraConfig()
David Trattnig's avatar
David Trattnig committed
40
engine = sa.create_engine(config.get_database_uri())
41
42
43
44
Base = declarative_base()
Base.metadata.bind = engine

class DB():
David Trattnig's avatar
David Trattnig committed
45
46
47
    session_factory = sessionmaker(bind=engine)
    Session = scoped_session(session_factory)
    session = Session()
48
49
50
    Model = Base


51
52

class AuraDatabaseModel():
53
54
55
56
57
    """
    AuraDataBaseModel.

    Holding all tables and relationships for the engine.
    """
58
59
    logger = None

60

61
    def __init__(self):
62
63
64
        """
        Constructor.
        """
65
66
        self.logger = logging.getLogger("AuraEngine")

67

68
    def store(self, add=False, commit=False):
69
70
71
        """
        Store to the database
        """
72
        if add:
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
73
            DB.session.add(self)
74
75
        else:
            DB.session.merge(self)
76
        if commit:
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
77
            DB.session.commit()
78

79

80
    def delete(self, commit=False):
81
82
83
        """
        Delete from the database
        """
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
84
        DB.session.delete(self)
85
        if commit:
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
86
            DB.session.commit()
87

88

89
90
91
92
93
94
95
96
    def refresh(self):
        """
        Refreshes the currect record
        """
        DB.session.expire(self)
        DB.session.refresh(self)


97
98
    def _asdict(self):
        return self.__dict__
99

100

101
102
103
    @staticmethod
    def init_database():
        """
104
        Initializes the database tables if they are not existing.
105
106
107
108
109
110
111

        Raises:
            sqlalchemy.exc.ProgrammingError:    In case the DB model is invalid
        """
        try:
            Playlist.is_empty()
        except sa.exc.ProgrammingError as e:
David Trattnig's avatar
David Trattnig committed
112
            is_available = True
113

David Trattnig's avatar
David Trattnig committed
114
115
116
117
118
119
120
121
            # PostgreSQL table not available
            if e.code == "f405":
                is_available = False
            # MariaDB table not available
            elif e.orig.args[0] == 1146:
                is_available = False

            if not is_available:
122
123
124
125
126
127
                model = AuraDatabaseModel()
                model.recreate_db()
            else:
                raise


128
129
    @staticmethod
    def recreate_db(systemexit = False):
130
        """
131
        Deletes all tables and re-creates the database.
David Trattnig's avatar
David Trattnig committed
132
        """
133
134
        Base.metadata.drop_all()
        Base.metadata.create_all()
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
135
        DB.session.commit()
136

137
138
        if systemexit:
            sys.exit(0)
139

140

141
142

#
David Trattnig's avatar
David Trattnig committed
143
#   TIMESLOT
144
145
#

146

147
class Timeslot(DB.Model, AuraDatabaseModel):
148
    """
David Trattnig's avatar
David Trattnig committed
149
    One specific timeslot for a show.
150
151
152
153
154
155
156
157

    Relationships:
        playlist (Playlist):            The specific playlist for this timeslot
        schedule_default (Playlist):    Some playlist played by default, when no specific playlist is assigned
        show_default (Playlist):        Some playlist played by default, when no default schedule playlist is assigned
        schedule_fallback (Playlist):   Some playlist played as fallback, when no specific playlist is assigned or if it is errorneous (includes silence detection)
        show_fallback (Playlist):       Some playlist played as fallback, when no schedule fallback playlist is assigned or if some specific playlist is errorneous (includes silence detection)
        station_fallback (Playlist):    Defined in the original AURA API but not implemented, as station fallbacks are handled locally
158
    """
159
    __tablename__ = 'timeslot'
Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
160

161
162
163
    # Primary keys
    id = Column(Integer, primary_key=True, autoincrement=True)

David Trattnig's avatar
David Trattnig committed
164
165
166
167
168
    # Relationships
    playlist = relationship("Playlist",
                            primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                Timeslot.playlist_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
                            uselist=False, back_populates="timeslot")
169
170
171
172
173
174
175
    default_schedule_playlist = relationship("Playlist",
                            primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                Timeslot.default_schedule_playlist_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
                            uselist=False, back_populates="timeslot")
    default_show_playlist = relationship("Playlist",
                            primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                Timeslot.default_show_playlist_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
David Trattnig's avatar
David Trattnig committed
176
                            uselist=False, back_populates="timeslot")
David Trattnig's avatar
David Trattnig committed
177
178
179
    schedule_fallback = relationship("Playlist",
                            primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                Timeslot.schedule_fallback_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
David Trattnig's avatar
David Trattnig committed
180
                            uselist=False, back_populates="timeslot")
David Trattnig's avatar
David Trattnig committed
181
182
183
184
185
186
187
188
189
190
    show_fallback = relationship("Playlist",
                            primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                Timeslot.show_fallback_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
                            uselist=False, back_populates="timeslot")
    station_fallback = relationship("Playlist",
                            primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                Timeslot.station_fallback_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
                            uselist=False, back_populates="timeslot")

    playlist_id = Column(Integer)
191
192
    default_schedule_playlist_id = Column(Integer)
    default_show_playlist_id = Column(Integer)
David Trattnig's avatar
David Trattnig committed
193
194
195
196
197
    schedule_fallback_id = Column(Integer)
    show_fallback_id = Column(Integer)
    station_fallback_id = Column(Integer)

    # Data
198
199
200
    timeslot_start = Column(DateTime, unique=True, index=True)
    timeslot_end = Column(DateTime, unique=True, index=True)
    timeslot_id = Column(Integer, unique=True)
201

202
    show_id = Column(Integer)
203
204
    show_name = Column(String(256))
    show_hosts = Column(String(256))
205
    funding_category = Column(String(256))
206
207
208
209
210
211
    comment = Column(String(512))
    languages = Column(String(256))
    type = Column(String(256))
    category = Column(String(256))
    topic = Column(String(256))
    musicfocus = Column(String(256))
212
    is_repetition = Column(Boolean())
David Trattnig's avatar
David Trattnig committed
213

214
215
    # Transients
    active_entry = None
216

217

218
    @staticmethod
219
220
221
222
223
224
225
    def for_datetime(date_time):
        """
        Select a timeslot at the given datetime.

        Args:
            date_time (datetime): date and time when the timeslot starts
        """
226
        return DB.session.query(Timeslot).filter(Timeslot.timeslot_start == date_time).first()
227

228

229
    @staticmethod
230
    def get_timeslots(date_from=datetime.date.today()):
231
        """
232
        Select all timeslots starting from `date_from` or from today if no
233
234
235
        parameter is passed.

        Args:
236
            date_from (datetime):   Select timeslots from this date and time on
237
238

        Returns:
239
            ([Timeslot]):           List of timeslots
240
        """
241
242
        timeslots = DB.session.query(Timeslot).\
            filter(Timeslot.timeslot_start >= date_from).\
David Trattnig's avatar
David Trattnig committed
243
            order_by(Timeslot.timeslot_start).all()
244
        return timeslots
245

246

247
    def set_active_entry(self, entry):
David Trattnig's avatar
David Trattnig committed
248
        """
249
250
251
252
        Sets the currently playing entry.

        Args:
            entry (PlaylistEntry): The entry playing right now
David Trattnig's avatar
David Trattnig committed
253
        """
254
        self.active_entry = entry
David Trattnig's avatar
David Trattnig committed
255
256


257
    def get_recent_entry(self):
David Trattnig's avatar
David Trattnig committed
258
        """
259
260
        Retrieves the most recent played or currently playing entry. This is used to fade-out
        the timeslot, when there is no other entry is following the current one.
David Trattnig's avatar
David Trattnig committed
261
        """
262
        return self.active_entry
David Trattnig's avatar
David Trattnig committed
263
264


David Trattnig's avatar
David Trattnig committed
265
266
267
    @hybrid_property
    def start_unix(self):
        """
268
        Start time of the timeslot in UNIX time.
David Trattnig's avatar
David Trattnig committed
269
        """
270
        return time.mktime(self.timeslot_start.timetuple())
David Trattnig's avatar
David Trattnig committed
271
272
273
274
275


    @hybrid_property
    def end_unix(self):
        """
276
        End time of the timeslot in UNIX time.
David Trattnig's avatar
David Trattnig committed
277
        """
278
        return time.mktime(self.timeslot_end.timetuple())
David Trattnig's avatar
David Trattnig committed
279
280


David Trattnig's avatar
David Trattnig committed
281
282
    def as_dict(self):
        """
283
        Returns the timeslot as a dictionary for serialization.
David Trattnig's avatar
David Trattnig committed
284
285
286
287
        """
        playlist = self.playlist

        return {
David Trattnig's avatar
David Trattnig committed
288
            "timeslot_id": self.timeslot_id,
289
290
            "timeslot_start": self.timeslot_start.isoformat(),
            "timeslot_end": self.timeslot_end.isoformat(),
David Trattnig's avatar
David Trattnig committed
291
292
293
294
295
296
297
298
299

            "topic": self.topic,
            "musicfocus": self.musicfocus,
            "funding_category": self.funding_category,
            "is_repetition": self.is_repetition,
            "category": self.category,
            "languages": self.languages,
            "comment": self.comment,
            "playlist_id": self.playlist_id,
300
            "schedule_default_id": self.schedule_default_id,
David Trattnig's avatar
David Trattnig committed
301
            "show_default_id": self.show_default_id,
David Trattnig's avatar
David Trattnig committed
302
303
304
305
306
307
308
309
310
311
312
313
314
            "schedule_fallback_id": self.schedule_fallback_id,
            "show_fallback_id": self.show_fallback_id,
            "station_fallback_id": self.station_fallback_id,

            "show": {
                "name": self.show_name,
                "host": self.show_hosts
            },

            "playlist": playlist
        }


315
316
317
318
    def __str__(self):
        """
        String representation of the object.
        """
David Trattnig's avatar
David Trattnig committed
319
320
        time_start = SimpleUtil.fmt_time(self.start_unix)
        time_end = SimpleUtil.fmt_time(self.end_unix)
321
        return "ID#%s [Show: %s, ShowID: %s | %s - %s ]" % (str(self.timeslot_id), self.show_name, str(self.show_id), time_start, time_end)
David Trattnig's avatar
David Trattnig committed
322
323


David Trattnig's avatar
David Trattnig committed
324
325
326
#
#   PLAYLIST
#
327

328
class Playlist(DB.Model, AuraDatabaseModel):
329
330
331
    """
    The playlist containing playlist entries.
    """
332
333
    __tablename__ = 'playlist'

David Trattnig's avatar
David Trattnig committed
334
    # Primary and Foreign Key
335
    artificial_id = Column(Integer, primary_key=True)
336
    timeslot_start = Column(DateTime, ForeignKey("timeslot.timeslot_start"))
David Trattnig's avatar
David Trattnig committed
337
338

    # Relationships
339
    timeslot = relationship("Timeslot", uselist=False, back_populates="playlist")
340
    entries = relationship("PlaylistEntry", back_populates="playlist")
David Trattnig's avatar
David Trattnig committed
341
342
343

    # Data
    playlist_id = Column(Integer, autoincrement=False)
344
    show_name = Column(String(256))
345
    entry_count = Column(Integer)
346

347

348
349
    @staticmethod
    def select_all():
350
351
352
        """
        Fetches all entries
        """
353
        all_entries = DB.session.query(Playlist).filter(Playlist.fallback_type == 0).all()
354
355
356
357
358
359
360
361

        cnt = 0
        for entry in all_entries:
            entry.programme_index = cnt
            cnt = cnt + 1

        return all_entries

362

363
    @staticmethod
364
    def select_playlist_for_timeslot(start_date, playlist_id):
365
        """
366
        Retrieves the playlist for the given timeslot identified by `start_date` and `playlist_id`
367
368
369
370
371
372

        Args:
            start_date (datetime):  Date and time when the playlist is scheduled
            playlist_id (Integer):  The ID of the playlist

        Returns:
373
            (Playlist):             The playlist, if existing for timeslot
374
375
376
377

        Raises:
            Exception:              In case there a inconsistent database state, such es multiple playlists for given date/time.
        """
David Trattnig's avatar
David Trattnig committed
378
        playlist = None
379
        playlists = DB.session.query(Playlist).filter(Playlist.timeslot_start == start_date).all()
380

David Trattnig's avatar
David Trattnig committed
381
382
383
        for p in playlists:
            if p.playlist_id == playlist_id:
                playlist = p
384

David Trattnig's avatar
David Trattnig committed
385
        return playlist
386

387

388
389
    @staticmethod
    def select_playlist(playlist_id):
390
391
392
393
394
        """
        Retrieves all paylists for that given playlist ID.

        Args:
            playlist_id (Integer):  The ID of the playlist
David Trattnig's avatar
David Trattnig committed
395

396
397
398
        Returns:
            (Array<Playlist>):      An array holding the playlists
        """
399
        return DB.session.query(Playlist).filter(Playlist.playlist_id == playlist_id).order_by(Playlist.timeslot_start).all()
David Trattnig's avatar
David Trattnig committed
400

401

402
403
404
405
406
407
408
409
410
411
412
    @staticmethod
    def is_empty():
        """
        Checks if the given is empty
        """
        try:
            return not DB.session.query(Playlist).one_or_none()
        except sa.orm.exc.MultipleResultsFound:
            return False


413
414
    @hybrid_property
    def start_unix(self):
415
416
417
        """
        Start time of the playlist in UNIX time.
        """
418
        return time.mktime(self.timeslot_start.timetuple())
419

420

421
422
423
424
425
    @hybrid_property
    def end_unix(self):
        """
        End time of the playlist in UNIX time.
        """
426
        return time.mktime(self.timeslot_start.timetuple()) + self.duration
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443


    @hybrid_property
    def duration(self):
        """
        Returns the total length of the playlist in seconds.

        Returns:
            (Integer):  Length in seconds
        """
        total = 0

        for entry in self.entries:
            total += entry.duration
        return total


David Trattnig's avatar
David Trattnig committed
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
    def as_dict(self):
        """
        Returns the playlist as a dictionary for serialization.
        """
        entries = []
        for e in self.entries:
            entries.append(e.as_dict())

        playlist = {
            "playlist_id": self.playlist_id,
            "fallback_type": self.fallback_type,
            "entry_count": self.entry_count,
            "entries": entries
        }
        return playlist


461
462
463
464
    def __str__(self):
        """
        String representation of the object.
        """
David Trattnig's avatar
David Trattnig committed
465
466
467
468
        time_start = SimpleUtil.fmt_time(self.start_unix)
        time_end = SimpleUtil.fmt_time(self.end_unix)
        return "ID#%s [items: %s | %s - %s]" % (str(self.playlist_id), str(self.entry_count), str(time_start), str(time_end))

469

David Trattnig's avatar
David Trattnig committed
470
471
472
#
#   PLAYLIST ENTRY
#
473

474
class PlaylistEntry(DB.Model, AuraDatabaseModel):
475
476
477
    """
    Playlist entries are the individual items of a playlist such as audio files.
    """
478
    __tablename__ = 'playlist_entry'
479

David Trattnig's avatar
David Trattnig committed
480
    # Primary and Foreign Keys
481
482
    artificial_id = Column(Integer, primary_key=True)
    artificial_playlist_id = Column(Integer, ForeignKey("playlist.artificial_id"))
483

David Trattnig's avatar
David Trattnig committed
484
485
486
487
488
489
    # Relationships
    playlist = relationship("Playlist", uselist=False, back_populates="entries")
    meta_data = relationship("PlaylistEntryMetaData", uselist=False, back_populates="entry")

    # Data
    entry_num = Column(Integer)
490
    duration = Column(BigInteger)
David Trattnig's avatar
David Trattnig committed
491
    volume = Column(Integer, ColumnDefault(100))
492
    source = Column(String(1024))
493
    entry_start = Column(DateTime)
David Trattnig's avatar
David Trattnig committed
494

495
    # Transients
496
    entry_start_actual = None # Assigned when the entry is actually played
497
    channel = None # Assigned when entry is actually played
David Trattnig's avatar
David Trattnig committed
498
    queue_state = None # Assigned when entry is about to be queued
David Trattnig's avatar
David Trattnig committed
499
    status = None # Assigned when state changes
500

501

Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
502
    @staticmethod
503
    def select_playlistentry_for_playlist(artificial_playlist_id, entry_num):
504
505
506
        """
        Selects one entry identified by `playlist_id` and `entry_num`.
        """
507
        return DB.session.query(PlaylistEntry).filter(PlaylistEntry.artificial_playlist_id == artificial_playlist_id, PlaylistEntry.entry_num == entry_num).first()
508

509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
    @staticmethod
    def delete_entry(artificial_playlist_id, entry_num):
        """
        Deletes the playlist entry and associated metadata.
        """
        entry = PlaylistEntry.select_playlistentry_for_playlist(artificial_playlist_id, entry_num)
        metadata = PlaylistEntryMetaData.select_metadata_for_entry(entry.artificial_id)
        metadata.delete()
        entry.delete()
        DB.session.commit()

    @staticmethod
    def count_entries(artificial_playlist_id):
        """
        Returns the count of all entries.
        """
        result = DB.session.query(PlaylistEntry).filter(PlaylistEntry.artificial_playlist_id == artificial_playlist_id).count()
        return result

528
529
530
531
    @hybrid_property
    def entry_end(self):
        return self.entry_start + datetime.timedelta(seconds=self.duration)

532
533
534
535
536
537
    @hybrid_property
    def start_unix(self):
        return time.mktime(self.entry_start.timetuple())

    @hybrid_property
    def end_unix(self):
538
        return time.mktime(self.entry_end.timetuple())
539

540

David Trattnig's avatar
David Trattnig committed
541
    def get_content_type(self):
542
        return ResourceUtil.get_content_type(self.source)
543

544

545
546
547
548
549
550
551
552
553
554
555
556
557
558
    def get_prev_entries(self):
        """
        Retrieves all previous entries as part of the current entry's playlist.

        Returns:
            (List):     List of PlaylistEntry
        """
        prev_entries = []
        for entry in self.playlist.entries:
            if entry.entry_start < self.entry_start:
                prev_entries.append(entry)
        return prev_entries


559
    def get_next_entries(self, timeslot_sensitive=True):
David Trattnig's avatar
David Trattnig committed
560
        """
561
        Retrieves all following entries as part of the current entry's playlist.
David Trattnig's avatar
David Trattnig committed
562

563
        Args:
564
565
            timeslot_sensitive (Boolean):   If `True` entries which start after \
                the end of the timeslot are excluded
566

David Trattnig's avatar
David Trattnig committed
567
568
569
570
571
572
        Returns:
            (List):     List of PlaylistEntry
        """
        next_entries = []
        for entry in self.playlist.entries:
            if entry.entry_start > self.entry_start:
573
574
                if timeslot_sensitive:
                    if entry.entry_start < self.playlist.timeslot.timeslot_end:
575
576
577
                        next_entries.append(entry)
                else:
                    next_entries.append(entry)
David Trattnig's avatar
David Trattnig committed
578
579
        return next_entries

Gottfried Gaisbauer's avatar
Gottfried Gaisbauer committed
580

581
582
583
584
585
586
    def as_dict(self):
        """
        Returns the entry as a dictionary for serialization.
        """
        if self.meta_data:
            return {
David Trattnig's avatar
David Trattnig committed
587
                "id": self.artificial_id,
588
589
590
591
592
593
594
                "duration": self.duration,
                "artist": self.meta_data.artist,
                "album": self.meta_data.album,
                "title": self.meta_data.title
            }
        return None

595
596
597
598
    def __str__(self):
        """
        String representation of the object.
        """
David Trattnig's avatar
David Trattnig committed
599
600
        time_start = SimpleUtil.fmt_time(self.start_unix)
        time_end = SimpleUtil.fmt_time(self.end_unix)
601
602
        track = self.source[-25:]
        return "PlaylistEntry #%s [%s - %s | %ssec | Source: ...%s]" % (str(self.artificial_id), time_start, time_end, self.duration, track)
603

David Trattnig's avatar
David Trattnig committed
604

605

David Trattnig's avatar
David Trattnig committed
606
607
608
609
#
#   PLAYLIST ENTRY METADATA
#

610

611
class PlaylistEntryMetaData(DB.Model, AuraDatabaseModel):
612
    """
David Trattnig's avatar
David Trattnig committed
613
    Metadata for a playlist entry such as the artist, album and track name.
614
    """
615
    __tablename__ = "playlist_entry_metadata"
616

David Trattnig's avatar
David Trattnig committed
617
    # Primary and Foreign Keys
618
619
    artificial_id = Column(Integer, primary_key=True)
    artificial_entry_id = Column(Integer, ForeignKey("playlist_entry.artificial_id"))
620

David Trattnig's avatar
David Trattnig committed
621
622
623
624
    # Relationships
    entry = relationship("PlaylistEntry", uselist=False, back_populates="meta_data")

    # Data
625
626
627
    artist = Column(String(256))
    title = Column(String(256))
    album = Column(String(256))
628

629
630
    @staticmethod
    def select_metadata_for_entry(artificial_playlistentry_id):
631
        return DB.session.query(PlaylistEntryMetaData).filter(PlaylistEntryMetaData.artificial_entry_id == artificial_playlistentry_id).first()
632
633