瀏覽代碼

Add better frontend, the first of many!

Colin Powell 2 年之前
父節點
當前提交
602d1e0ddb

+ 70 - 0
vrobbler/apps/music/aggregators.py

@@ -0,0 +1,70 @@
+from django.db.models import Q, Count, Sum
+from typing import List, Optional
+from scrobbles.models import Scrobble
+from music.models import Track, Artist
+from videos.models import Video
+
+from django.utils import timezone
+from datetime import datetime, timedelta
+
+
+NOW = timezone.now()
+START_OF_TODAY = datetime.combine(NOW.date(), datetime.min.time(), NOW.tzinfo)
+STARTING_DAY_OF_CURRENT_WEEK = NOW.date() - timedelta(days=NOW.today().isoweekday() % 7)
+STARTING_DAY_OF_CURRENT_MONTH = NOW.date().replace(day=1)
+STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
+
+
+def scrobble_counts():
+    finished_scrobbles_qs = Scrobble.objects.filter(in_progress=False)
+    data = {}
+    data['today'] = finished_scrobbles_qs.filter(timestamp__gte=START_OF_TODAY).count()
+    data['week'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK).count()
+    data['month'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH).count()
+    data['year'] = finished_scrobbles_qs.filter(timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR).count()
+    data['alltime'] = finished_scrobbles_qs.count()
+    return data
+
+def week_of_scrobbles(media: str='tracks') -> dict[str, int]:
+    scrobble_day_dict= {}
+    media_filter = Q(track__isnull=True)
+
+    for day in range(1,8):
+        start = START_OF_TODAY - timedelta(days=day)
+        end = datetime.combine(start, datetime.max.time(), NOW.tzinfo)
+        day_of_week = start.strftime('%A')
+        if media == 'movies':
+            media_filter = Q(video__videotype=Video.VideoType.MOVIE)
+        if media == 'series':
+            media_filter = Q(video__videotype=Video.VideoType.MOVIE)
+        scrobble_day_dict[day_of_week] = Scrobble.objects.filter(media_filter).filter(timestamp__lte=START_OF_TODAY, timestamp__gt=end, in_progress=False).count()
+
+    return scrobble_day_dict
+
+def top_tracks(filter: str="today", limit: int=15) -> List["Track"]:
+    time_filter = Q(scrobble__timestamp__gte=START_OF_TODAY)
+    if filter == "week":
+        time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
+    if filter == "month":
+        time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
+    if filter == "year":
+        time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
+
+    return Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
+
+def top_artists(filter: str="today", limit: int=15) -> List["Artist"]:
+    time_filter = Q(track__scrobble__timestamp__gte=START_OF_TODAY)
+    if filter == "week":
+        time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
+    if filter == "month":
+        time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
+    if filter == "year":
+        time_filter = Q(track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
+
+    return Artist.objects.annotate(num_scrobbles=Sum("track__scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
+
+def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
+    return (
+        Scrobble.objects.filter(track__artist=artist_id)
+        .count()
+    )

+ 12 - 4
vrobbler/apps/scrobbles/views.py

@@ -21,6 +21,7 @@ from scrobbles.models import Scrobble
 from scrobbles.serializers import ScrobbleSerializer
 from scrobbles.utils import convert_to_seconds
 from videos.models import Video
+from vrobbler.apps.music.aggregators import scrobble_counts, top_tracks, week_of_scrobbles
 
 logger = logging.getLogger(__name__)
 
@@ -43,19 +44,26 @@ class RecentScrobbleList(ListView):
     def get_context_data(self, **kwargs):
         data = super().get_context_data(**kwargs)
         now = timezone.now()
-        last_three_minutes = timezone.now() - timedelta(minutes=3)
+        last_eight_minutes = timezone.now() - timedelta(minutes=8)
         # Find scrobbles from the last 10 minutes
         data['now_playing_list'] = Scrobble.objects.filter(
             in_progress=True,
-            timestamp__gte=last_three_minutes,
+            is_paused=False,
+            timestamp__gte=last_eight_minutes,
             timestamp__lte=now,
         )
+        data['video_scrobble_list'] = Scrobble.objects.filter(video__isnull=False, in_progress=False).order_by('-timestamp')[:10]
+        data['top_daily_tracks'] = top_tracks()
+        data['top_weekly_tracks'] = top_tracks(filter='week')
+        data['top_monthly_tracks'] = top_tracks(filter='month')
+        data["weekly_data"] = week_of_scrobbles()
+        data['counts'] = scrobble_counts()
         return data
 
     def get_queryset(self):
-        return Scrobble.objects.filter(in_progress=False).order_by(
+        return Scrobble.objects.filter(track__isnull=False, in_progress=False).order_by(
             '-timestamp'
-        )
+        )[:25]
 
 
 @csrf_exempt

+ 1 - 0
vrobbler/settings.py

@@ -89,6 +89,7 @@ INSTALLED_APPS = [
     "rest_framework",
     "allauth",
     "allauth.account",
+    "allauth.socialaccount",
     "django_celery_results",
 ]
 

+ 254 - 58
vrobbler/templates/base.html

@@ -1,4 +1,5 @@
 {% load static %}
+{% load humanize %}
 <!doctype html>
 <html class="no-js" lang="">
     <head>
@@ -7,11 +8,10 @@
         <meta http-equiv="x-ua-compatible" content="ie=edge">
         <meta name="description" content="">
         <meta name="viewport" content="width=device-width, initial-scale=1">
-        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
+        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
         <script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
-        <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
         <script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
-
         <style type="text/css">
         dl {
             display: flex;
@@ -48,76 +48,272 @@
             border-radius: 3px;
             transition: width 500ms ease-in-out;
         }
+        .bd-placeholder-img {
+            font-size: 1.125rem;
+            text-anchor: middle;
+            -webkit-user-select: none;
+            -moz-user-select: none;
+            user-select: none;
+        }
+
+        @media (min-width: 768px) {
+            .bd-placeholder-img-lg {
+            font-size: 3.5rem;
+            }
+        }
+        body {
+        font-size: .875rem;
+        }
+
+        .feather {
+        width: 16px;
+        height: 16px;
+        vertical-align: text-bottom;
+        }
+
+        /*
+        * Sidebar
+        */
+
+        .sidebar {
+        position: fixed;
+        top: 0;
+        /* rtl:raw:
+        right: 0;
+        */
+        bottom: 0;
+        /* rtl:remove */
+        left: 0;
+        z-index: 100; /* Behind the navbar */
+        padding: 48px 0 0; /* Height of navbar */
+        box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
+        }
+
+        @media (max-width: 767.98px) {
+        .sidebar {
+            top: 5rem;
+        }
+        }
+
+        .sidebar-sticky {
+        position: relative;
+        top: 0;
+        height: calc(100vh - 48px);
+        padding-top: .5rem;
+        overflow-x: hidden;
+        overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
+        }
+
+        .sidebar .nav-link {
+        font-weight: 500;
+        color: #333;
+        }
+
+        .sidebar .nav-link .feather {
+        margin-right: 4px;
+        color: #727272;
+        }
+
+        .sidebar .nav-link.active {
+        color: #2470dc;
+        }
+
+        .sidebar .nav-link:hover .feather,
+        .sidebar .nav-link.active .feather {
+        color: inherit;
+        }
+
+        .sidebar-heading {
+        font-size: .75rem;
+        text-transform: uppercase;
+        }
+
+        /*
+        * Navbar
+        */
+
+        .navbar-brand {
+        padding-top: .75rem;
+        padding-bottom: .75rem;
+        font-size: 1rem;
+        background-color: rgba(0, 0, 0, .25);
+        box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
+        }
+
+        .navbar .navbar-toggler {
+        top: .25rem;
+        right: 1rem;
+        }
+
+        .navbar .form-control {
+        padding: .75rem 1rem;
+        border-width: 0;
+        border-radius: 0;
+        }
+
+        .form-control-dark {
+        color: #fff;
+        background-color: rgba(255, 255, 255, .1);
+        border-color: rgba(255, 255, 255, .1);
+        }
+
+        .form-control-dark:focus {
+        border-color: transparent;
+        box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
+        }
+
         </style>
         {% block head_extra %}{% endblock %}
 
         <link rel="apple-touch-icon" href="/apple-touch-icon.png">
         <!-- Place favicon.ico in the root directory -->
-
-        <script>
-        function checkUpdate(){
-            $.get('/library/update/status/', function(data) {
-                $('#library-update-status').html("");
-                console.log('Checking for task');
-                setTimeout(checkUpdate,5000);
-            });
-        }
-        </script>
-
     </head>
     <body>
-        <!--[if lt IE 8]>
-            <p class="browserupgrade">
-            You are using an <strong>outdated</strong> browser. Please
-            <a href="http://browsehappy.com/">upgrade your browser</a> to improve
-            your experience.
-            </p>
-        <![endif]-->
-        <div class="container">
-            <nav class="navbar navbar-expand-lg navbar-light bg-light">
-            <a class="navbar-brand" href="/">Vrobbler</a>
-            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+        <header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
+            <a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">Vrobbler</a>
+            <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
                 <span class="navbar-toggler-icon"></span>
             </button>
-
-            <div class="collapse navbar-collapse" id="navbarSupportedContent">
-                <ul class="navbar-nav mr-auto">
-                <li class="nav-item">
-                    <a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a>
-                </li>
-                <li class="nav-item dropdown">
-                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Movies</a>
-                    <div class="dropdown-menu" aria-labelledby="navbarDropdown">
-                        <a class="dropdown-item" href="{% url "videos:movie_list" %}">All</a>
-                        {% for movie in movie_list %}
-                        <a class="dropdown-item" href="{{movie.get_absolute_url}}">{{movie.title}}</a>
-                        {% endfor %}
-                    </div>
-                </li>
-                <li class="nav-item dropdown">
-                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Shows</a>
-                    <div class="dropdown-menu" aria-labelledby="navbarDropdown">
-                        <a class="dropdown-item" href="{ url "games:gamecollection_list" %}">All</a>
-                        {% for series in series_list %}
-                        <a class="dropdown-item" href="{{series.get_absolute_url}}">{{series.name}}</a>
-                        {% endfor %}
-                    </div>
-                </li>
-                </ul>
+            <input class="form-control form-control-dark w-100" type="text" placeholder="Search" aria-label="Search">
+            <div class="navbar-nav">
+                <div class="nav-item text-nowrap">
+                {% if user.is_authenticated %}
+                <a class="nav-link px-3" href="{% url "account_logout" %}">Sign out</a>
+                {% else %}
+                <a class="nav-link px-3" href="{% url "account_login" %}">Sign in</a>
+                {% endif %}
+                </div>
             </div>
-            {% if request.user.is_authenticated %}
-            <a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a>
-            {% else %}
-            <a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a>
-            {% endif %}
-            </nav>
+        </header>
+
+        <div class="container-fluid">
+            <div class="row">
+                <nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
+                    <div class="position-sticky pt-3">
+                        {% if now_playing_list %}
+                        <ul style="padding-right:10px;">
+                            <b>Now playing</b>
+                            {% for scrobble in now_playing_list %}
+                            {% if scrobble.video %}
+                            <div>
+                                {{scrobble.video.title}}<br/>
+                                <small>{{scrobble.created|naturaltime}}<br/>
+                                    from {{scrobble.source}}</small>
+                                <div class="progress-bar">
+                                    <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
+                                </div>
+                            </div>
+                            {% endif %}
+                            {% if scrobble.track %}
+                            <div>
+                                {{scrobble.track.title}}<br/>
+                                <em>{{scrobble.track.artist}}</em><br/>
+                                <small>{{scrobble.created|naturaltime}}<br/>
+                                    from {{scrobble.source}}</small>
+                                <div class="progress-bar" style="margin-right:5px;">
+                                    <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
+                                </div>
+                            </div>
+                            {% endif %}
+                            <hr/>
+                            {% endfor %}
+                        </ul>
+                        {% endif %}
+
 
-            <h1>{% block title %}{% endblock %}</h1>
+                        <ul class="nav flex-column">
+                            <li class="nav-item">
+                                <a class="nav-link active" aria-current="page" href="#">
+                                <span data-feather="music"></span>
+                                Tracks
+                                </a>
+                            </li>
+                            <li class="nav-item">
+                                <a class="nav-link" href="#">
+                                <span data-feather="user"></span>
+                                Artists
+                                </a>
+                            </li>
+                            <li class="nav-item">
+                                <a class="nav-link" href="#">
+                                <span data-feather="film"></span>
+                                Movies
+                                </a>
+                            </li>
+                            <li class="nav-item">
+                                <a class="nav-link" href="#">
+                                <span data-feather="tv"></span>
+                                Series
+                                </a>
+                            </li>
+                            {% if user.is_authenticated %}
+                            <li class="nav-item">
+                                <a class="nav-link" href="/admin/">
+                                <span data-feather="key"></span>
+                                Admin
+                                </a>
+                            </li>
+                            {% endif %}
+                        </ul>
+                        {% block extra_nav %}
+                        {% endblock %}
+
+                    </div>
+                </nav>
 
-            <div>
                 {% block content %}
                 {% endblock %}
             </div>
         </div>
+        {% block extra_js %}
+        <script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
+        <script><!-- comment ------------------------------------------------->
+            /* globals Chart:false, feather:false */
+            (function () {
+            'use strict'
+
+            feather.replace({ 'aria-hidden': 'true' })
+
+            // Graphs
+            var ctx = document.getElementById('myChart')
+            // eslint-disable-next-line no-unused-vars
+            var myChart = new Chart(ctx, {
+                type: 'line',
+                data: {
+                labels: [
+                    {% for day in weekly_data.keys %}
+                    "{{day}}"{% if not forloop.last %},{% endif %}
+                    {% endfor %}
+                ],
+                datasets: [{
+                    data: [
+                    {% for count in weekly_data.values %}
+                    {{count}}{% if not forloop.last %},{% endif %}
+                    {% endfor %}
+                    ],
+                    lineTension: 0,
+                    backgroundColor: 'transparent',
+                    borderColor: '#007bff',
+                    borderWidth: 4,
+                    pointBackgroundColor: '#007bff'
+                }]
+                },
+                options: {
+                scales: {
+                    yAxes: [{
+                    ticks: {
+                        beginAtZero: false
+                    }
+                    }]
+                },
+                legend: {
+                    display: false
+                }
+                }
+            })
+            })()
+
+        </script>
+        {% endblock %}
     </body>
 </html>

+ 92 - 43
vrobbler/templates/scrobbles/scrobble_list.html

@@ -1,50 +1,99 @@
 {% extends "base.html" %}
-
 {% load humanize %}
 
-{% block title %}{% endblock %}
 
 {% block content %}
-    {% if now_playing_list %}
-    <h2>Now playing</h2>
-    {% for scrobble in now_playing_list %}
-        {% if scrobble.video %}
-            <dl class="latest-scrobble">
-                <dt>{{scrobble.video.title}} - {{scrobble.video}}</dt>
-                <dd>
-                    Started {{scrobble.created|naturaltime}} from {{scrobble.source}}
+<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
+    <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+        <h1 class="h2">Dashboard</h1>
+        <div class="btn-toolbar mb-2 mb-md-0">
+        <div class="btn-group me-2">
+            <button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
+            <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
+        </div>
+        <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
+            <span data-feather="calendar"></span>
+            This week
+        </button>
+        </div>
+    </div>
+
+    <canvas class="my-4 w-100" id="myChart" width="900" height="380"></canvas>
+
+
+    <h2>Top this week</h2>
+    <div class="table-responsive">
+        <table class="table table-striped table-sm">
+        <thead>
+            <tr>
+                <th scope="col">#</th>
+                <th scope="col">Track</th>
+                <th scope="col">Artist</th>
+                <th scope="col">Album</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for track in top_weekly_tracks %}
+            <tr>
+                <td>{{track.num_scrobbles}}</td>
+                <td>{{track.title}}</td>
+                <td>{{track.artist.name}}</td>
+                <td>{{track.album.name}}</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+        </table>
+    </div>
+
+    <h2>Latest scrobbles</h2>
+    <p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
+    <div class="table-responsive">
+        <table class="table table-striped table-sm">
+        <thead>
+            <tr>
+            <th scope="col">Time</th>
+            <th scope="col">Track</th>
+            <th scope="col">Artist</th>
+            <th scope="col">Source</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for scrobble in object_list %}
+            <tr>
+                <td>{{scrobble.timestamp|naturaltime}}</td>
+                <td>{{scrobble.track.title}}</td>
+                <td>{{scrobble.track.artist.name}}</td>
+                <td>{{scrobble.source}}</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+        </table>
+    </div>
 
-                    <div class="progress-bar">
-                        <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
-                    </div>
-                </dd>
-            </dl>
-        {% endif %}
-        {% if scrobble.track %}
-            <dl class="latest-scrobble">
-                <dt>{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}</dt>
-                <dd>
-                    Started {{scrobble.created|naturaltime}} from {{scrobble.source}}
-                    <div class="progress-bar">
-                        <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
-                    </div>
-                </dd>
-            </dl>
-        {% endif %}
-        <br />
-    {% endfor %}
-    {% endif %}
-    <h2>Last scrobbles</h2>
-    <ul>
-        {% for scrobble in object_list %}
-        <li>
-            {{scrobble.timestamp|naturaltime}}:
-            {% if scrobble.video %}
-            🎥 watched <a href="{{scrobble.video.imdb_link}}">{{scrobble.video}}{% if scrobble.video.video_type == 'E' %} - {{scrobble.video.title}}{% endif %}</a></li>
-            {% endif %}
-            {% if scrobble.track %}
-            🎶 listened to <a href="{{scrobble.track.mb_link}}">{{scrobble.track.title}}</a> by <a href="{{scrobble.track.artist.mb_link}}">{{scrobble.track.artist}}</a></li>
-            {% endif %}
-        {% endfor %}
-    </ul>
+    <h2>Latest watched</h2>
+    <div class="table-responsive">
+        <table class="table table-striped table-sm">
+        <thead>
+            <tr>
+            <th scope="col">Time</th>
+            <th scope="col">Title</th>
+            <th scope="col">Series</th>
+            <th scope="col">Season & Episode</th>
+            <th scope="col">Source</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for scrobble in video_scrobble_list %}
+            <tr>
+                <td>{{scrobble.timestamp|naturaltime}}</td>
+                <td>{{scrobble.video.title}}</td>
+                <td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
+                <td>{% if scrobble.video.tv_series %}{{scrobble.video.season_number}}, {{scrobble.video.episode_number}}{% endif %}</td>
+                <td>{{scrobble.source}}</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+        </table>
+    </div>
+</main>
 {% endblock %}