Переглянути джерело

Added update playlist feature

sleepytaco 3 роки тому
батько
коміт
00284071b9
31 змінених файлів з 1207 додано та 314 видалено
  1. 1 1
      UnTube/settings.py
  2. 0 77
      apps/main/migrations/0001_initial.py
  3. 0 17
      apps/main/migrations/0002_remove_playlist_is_in_db.py
  4. 0 18
      apps/main/migrations/0003_playlist_is_in_db.py
  5. 0 18
      apps/main/migrations/0004_playlist_user_label.py
  6. 0 0
      apps/main/migrations/__init__.py
  7. 326 5
      apps/main/models.py
  8. 29 0
      apps/main/static/svg-loaders/audio.svg
  9. 47 0
      apps/main/static/svg-loaders/ball-triangle.svg
  10. 52 0
      apps/main/static/svg-loaders/bars.svg
  11. 20 0
      apps/main/static/svg-loaders/circles.svg
  12. 56 0
      apps/main/static/svg-loaders/grid.svg
  13. 18 0
      apps/main/static/svg-loaders/hearts.svg
  14. 17 0
      apps/main/static/svg-loaders/oval.svg
  15. 37 0
      apps/main/static/svg-loaders/puff.svg
  16. 42 0
      apps/main/static/svg-loaders/rings.svg
  17. 55 0
      apps/main/static/svg-loaders/spinning-circles.svg
  18. 32 0
      apps/main/static/svg-loaders/tail-spin.svg
  19. 33 0
      apps/main/static/svg-loaders/three-dots.svg
  20. 38 0
      apps/main/templates/intercooler/manage_playlists_create.html
  21. 1 1
      apps/main/templates/intercooler/manage_playlists_import.html
  22. 1 1
      apps/main/templates/intercooler/progress_bar.html
  23. 210 0
      apps/main/templates/intercooler/updated_playlist.html
  24. 69 31
      apps/main/templates/intercooler/videos.html
  25. 34 40
      apps/main/templates/view_playlist.html
  26. 1 0
      apps/main/urls.py
  27. 86 2
      apps/main/views.py
  28. 0 32
      apps/users/migrations/0001_initial.py
  29. 0 0
      apps/users/migrations/__init__.py
  30. 0 1
      apps/users/urls.py
  31. 2 70
      templates/base.html

+ 1 - 1
UnTube/settings.py

@@ -104,7 +104,7 @@ SOCIALACCOUNT_PROVIDERS = {
     }
 }
 
-SITE_ID = 3
+SITE_ID = 6
 
 LOGIN_URL = '/'
 

+ 0 - 77
apps/main/migrations/0001_initial.py

@@ -1,77 +0,0 @@
-# Generated by Django 3.2.3 on 2021-06-06 06:04
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-        ('users', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='Playlist',
-            fields=[
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('playlist_id', models.CharField(max_length=150)),
-                ('name', models.CharField(blank=True, max_length=150)),
-                ('thumbnail_url', models.CharField(blank=True, max_length=420)),
-                ('description', models.CharField(default='No description', max_length=420)),
-                ('video_count', models.IntegerField(default=0)),
-                ('published_at', models.DateTimeField(blank=True, null=True)),
-                ('playlist_yt_player_HTML', models.CharField(blank=True, max_length=420)),
-                ('playlist_duration', models.CharField(blank=True, max_length=69)),
-                ('playlist_duration_in_seconds', models.IntegerField(default=0)),
-                ('has_unavailable_videos', models.BooleanField(default=False)),
-                ('channel_id', models.CharField(blank=True, max_length=420)),
-                ('channel_name', models.CharField(blank=True, max_length=420)),
-                ('user_notes', models.CharField(default='', max_length=420)),
-                ('marked_as', models.CharField(default='', max_length=100)),
-                ('is_favorite', models.BooleanField(blank=True, default=False)),
-                ('num_of_accesses', models.IntegerField(default='0')),
-                ('has_playlist_changed', models.BooleanField(default=False)),
-                ('is_private_on_yt', models.BooleanField(default=False)),
-                ('is_from_yt', models.BooleanField(default=True)),
-                ('has_duplicate_videos', models.BooleanField(default=False)),
-                ('view_in_grid_mode', models.BooleanField(default=False)),
-                ('is_in_db', models.BooleanField(default=False)),
-                ('created_at', models.DateTimeField(auto_now_add=True)),
-                ('updated_at', models.DateTimeField(auto_now=True)),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlists', to='users.profile')),
-            ],
-        ),
-        migrations.CreateModel(
-            name='Video',
-            fields=[
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('video_id', models.CharField(max_length=100)),
-                ('name', models.CharField(blank=True, max_length=100)),
-                ('duration', models.CharField(blank=True, max_length=100)),
-                ('duration_in_seconds', models.IntegerField(default=0)),
-                ('thumbnail_url', models.CharField(blank=True, max_length=420)),
-                ('published_at', models.DateTimeField(blank=True, null=True)),
-                ('description', models.CharField(default='', max_length=420)),
-                ('has_cc', models.BooleanField(blank=True, default=False, null=True)),
-                ('user_notes', models.CharField(default='', max_length=420)),
-                ('view_count', models.IntegerField(default=0)),
-                ('like_count', models.IntegerField(default=0)),
-                ('dislike_count', models.IntegerField(default=0)),
-                ('yt_player_HTML', models.CharField(blank=True, max_length=420)),
-                ('channel_id', models.CharField(blank=True, max_length=420)),
-                ('channel_name', models.CharField(blank=True, max_length=420)),
-                ('video_position', models.CharField(blank=True, max_length=69)),
-                ('is_duplicate', models.BooleanField(default=False)),
-                ('is_unavailable_on_yt', models.BooleanField(default=False)),
-                ('was_deleted_on_yt', models.BooleanField(default=False)),
-                ('is_marked_as_watched', models.BooleanField(blank=True, default=False)),
-                ('is_favorite', models.BooleanField(blank=True, default=False)),
-                ('num_of_accesses', models.CharField(default='0', max_length=69)),
-                ('user_label', models.CharField(default='', max_length=100)),
-                ('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='main.playlist')),
-            ],
-        ),
-    ]

+ 0 - 17
apps/main/migrations/0002_remove_playlist_is_in_db.py

@@ -1,17 +0,0 @@
-# Generated by Django 3.2.3 on 2021-06-06 06:05
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('main', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.RemoveField(
-            model_name='playlist',
-            name='is_in_db',
-        ),
-    ]

+ 0 - 18
apps/main/migrations/0003_playlist_is_in_db.py

@@ -1,18 +0,0 @@
-# Generated by Django 3.2.3 on 2021-06-06 06:05
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('main', '0002_remove_playlist_is_in_db'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='playlist',
-            name='is_in_db',
-            field=models.BooleanField(default=False),
-        ),
-    ]

+ 0 - 18
apps/main/migrations/0004_playlist_user_label.py

@@ -1,18 +0,0 @@
-# Generated by Django 3.2.3 on 2021-06-08 03:37
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('main', '0003_playlist_is_in_db'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='playlist',
-            name='user_label',
-            field=models.CharField(default='', max_length=100),
-        ),
-    ]

+ 0 - 0
apps/main/migrations/__init__.py


+ 326 - 5
apps/main/models.py

@@ -1,3 +1,5 @@
+import datetime
+
 import googleapiclient.errors
 from django.db import models
 from django.db.models import Q
@@ -99,9 +101,18 @@ class PlaylistManager(models.Manager):
 
     # Returns True if the video count for a playlist on UnTube and video count on same playlist on YouTube is different
     def checkIfPlaylistChangedOnYT(self, user, pl_id):
-
+        """
+        If full_scan is true, the whole playlist (i.e each and every video from the PL on YT and PL on UT, is scanned and compared)
+        is scanned to see if there are any missing/deleted/newly added videos. This will be only be done
+        weekly by looking at the playlist.last_full_scan_at
+
+        If full_scan is False, only the playlist count difference on YT and UT is checked on every visit
+        to the playlist page. This is done everytime.
+        """
         credentials = self.getCredentials(user)
 
+        playlist = user.profile.playlists.get(playlist_id=pl_id)
+
         with build('youtube', 'v3', credentials=credentials) as youtube:
             pl_request = youtube.playlists().list(
                 part='contentDetails, snippet, id, status',
@@ -142,10 +153,84 @@ class PlaylistManager(models.Manager):
                 # POSSIBLE CASES:
                 # 1. PLAYLIST HAS DUPLICATE VIDEOS, DELETED VIDS, UNAVAILABLE VIDS
 
-                # check if playlist count changed on youtube
+                # check if playlist changed on youtube
                 if playlist.video_count != item['contentDetails']['itemCount']:
                     return [-1, item['contentDetails']['itemCount']]
 
+        # if its been a week since the last full scan, do a full playlist scan
+        # basically checks all the playlist video for any updates
+        if playlist.last_full_scan_at + datetime.timedelta(days=7) < datetime.datetime.now(pytz.utc):
+            print("DOING A FULL SCAN")
+            current_video_ids = [video.video_id for video in playlist.videos.all()]
+
+            deleted_videos, unavailable_videos, added_videos = 0, 0, 0
+
+            ### GET ALL VIDEO IDS FROM THE PLAYLIST
+            video_ids = []  # stores list of all video ids for a given playlist
+            with build('youtube', 'v3', credentials=credentials) as youtube:
+                pl_request = youtube.playlistItems().list(
+                    part='contentDetails, snippet, status',
+                    playlistId=playlist_id,  # get all playlist videos details for this playlist id
+                    maxResults=50
+                )
+
+                # execute the above request, and store the response
+                pl_response = pl_request.execute()
+
+                for item in pl_response['items']:
+                    video_id = item['contentDetails']['videoId']
+
+                    if playlist.videos.filter(video_id=video_id).count() == 0:  # video DNE in playlist, its a new vid
+                        added_videos += 1
+                        video_ids.append(video_id)
+                    else:  # video found in db
+                        if video_id in current_video_ids:
+                            video_ids.append(video_id)
+                            current_video_ids.remove(video_id)
+
+                        video = playlist.videos.get(video_id=video_id)
+                        # check if the video became unavailable on youtube
+                        if not video.is_unavailable_on_yt:
+                            if (item['snippet']['title'] == "Deleted video" and
+                                item['snippet']['description'] == "This video is unavailable.") or (
+                                    item['snippet']['title'] == "Private video" and item['snippet'][
+                                'description'] == "This video is private."):
+                                unavailable_videos += 1
+
+                while True:
+                    try:
+                        pl_request = youtube.playlistItems().list_next(pl_request, pl_response)
+                        pl_response = pl_request.execute()
+                        for item in pl_response['items']:
+                            video_id = item['contentDetails']['videoId']
+
+                            if playlist.videos.filter(video_id=video_id).count() == 0:  # video DNE
+                                added_videos += 1
+                                video_ids.append(video_id)
+                            else:  # video found in db
+                                if video_id in current_video_ids:
+                                    video_ids.append(video_id)
+                                    current_video_ids.remove(video_id)
+
+                                video = playlist.videos.get(video_id=video_id)
+                                # check if the video became unavailable on youtube
+                                if not video.is_unavailable_on_yt:
+                                    if (item['snippet']['title'] == "Deleted video" and
+                                        item['snippet']['description'] == "This video is unavailable.") or (
+                                            item['snippet']['title'] == "Private video" and item['snippet'][
+                                        'description'] == "This video is private."):
+                                        unavailable_videos += 1
+
+                    except AttributeError:
+                        break
+
+            playlist.last_full_scan_at = datetime.datetime.now(pytz.utc)
+
+            playlist.save()
+
+            deleted_videos = len(current_video_ids)  # left out video ids
+
+            return [1, deleted_videos, unavailable_videos, added_videos]
         return [0, "no change"]
 
     # Used to check if the user has a vaild YouTube channel
@@ -225,6 +310,7 @@ class PlaylistManager(models.Manager):
                 playlist = current_user.playlists.get(playlist_id__exact=playlist_id)
                 print(f"PLAYLIST {playlist.name} ALREADY EXISTS IN DB")
 
+
                 # POSSIBLE CASES:
                 # 1. PLAYLIST HAS DUPLICATE VIDEOS, DELETED VIDS, UNAVAILABLE VIDS
 
@@ -277,6 +363,7 @@ class PlaylistManager(models.Manager):
                                     item['snippet']['title'] == "Private video" and item['snippet'][
                                 'description'] == "This video is private."):
                                 video = Video(
+                                    playlist_item_id=item["id"],
                                     video_id=video_id,
                                     name=item['snippet']['title'],
                                     is_unavailable_on_yt=True,
@@ -286,6 +373,7 @@ class PlaylistManager(models.Manager):
                                 video.save()
                             else:
                                 video = Video(
+                                    playlist_item_id=item["id"],
                                     video_id=video_id,
                                     published_at=item['contentDetails']['videoPublishedAt'] if 'videoPublishedAt' in
                                                                                                item[
@@ -328,6 +416,7 @@ class PlaylistManager(models.Manager):
                                             item['snippet']['description'] == "This video is private."):
 
                                         video = Video(
+                                            playlist_item_id=item["id"],
                                             video_id=video_id,
                                             published_at=item['contentDetails'][
                                                 'videoPublishedAt'] if 'videoPublishedAt' in item[
@@ -340,6 +429,7 @@ class PlaylistManager(models.Manager):
                                         video.save()
                                     else:
                                         video = Video(
+                                            playlist_item_id=item["id"],
                                             video_id=video_id,
                                             published_at=item['contentDetails'][
                                                 'videoPublishedAt'] if 'videoPublishedAt' in item[
@@ -510,7 +600,6 @@ class PlaylistManager(models.Manager):
         return result
 
     def getAllVideosForPlaylist(self, user, playlist_id):
-
         current_user = user.profile
 
         credentials = self.getCredentials(user)
@@ -538,6 +627,7 @@ class PlaylistManager(models.Manager):
                             item['snippet']['title'] == "Private video" and item['snippet'][
                         'description'] == "This video is private."):
                         video = Video(
+                            playlist_item_id=item["id"],
                             video_id=video_id,
                             name=item['snippet']['title'],
                             is_unavailable_on_yt=True,
@@ -547,6 +637,7 @@ class PlaylistManager(models.Manager):
                         video.save()
                     else:
                         video = Video(
+                            playlist_item_id=item["id"],
                             video_id=video_id,
                             published_at=item['contentDetails']['videoPublishedAt'] if 'videoPublishedAt' in
                                                                                        item[
@@ -589,6 +680,7 @@ class PlaylistManager(models.Manager):
                                 'description'] == "This video is private."):
 
                                 video = Video(
+                                    playlist_item_id=item["id"],
                                     video_id=video_id,
                                     published_at=item['contentDetails'][
                                         'videoPublishedAt'] if 'videoPublishedAt' in item[
@@ -601,6 +693,7 @@ class PlaylistManager(models.Manager):
                                 video.save()
                             else:
                                 video = Video(
+                                    playlist_item_id=item["id"],
                                     video_id=video_id,
                                     published_at=item['contentDetails'][
                                         'videoPublishedAt'] if 'videoPublishedAt' in item[
@@ -675,6 +768,219 @@ class PlaylistManager(models.Manager):
 
         playlist.save()
 
+    def updatePlaylist(self, user, playlist_id):
+        current_user = user.profile
+
+        credentials = self.getCredentials(user)
+
+        playlist = current_user.playlists.get(playlist_id__exact=playlist_id)
+        playlist.has_duplicate_videos = False  # reset this to false for now
+
+        current_video_ids = [video.video_id for video in playlist.videos.all()]
+
+        updated_playlist_video_count = 0
+
+        deleted_videos, unavailable_videos, added_videos = [], [], []
+
+        ### GET ALL VIDEO IDS FROM THE PLAYLIST
+        video_ids = []  # stores list of all video ids for a given playlist
+        with build('youtube', 'v3', credentials=credentials) as youtube:
+            pl_request = youtube.playlistItems().list(
+                part='contentDetails, snippet, status',
+                playlistId=playlist_id,  # get all playlist videos details for this playlist id
+                maxResults=50
+            )
+
+            # execute the above request, and store the response
+            pl_response = pl_request.execute()
+
+            print("ESTIMATED VIDEO IDS FROM RESPONSE", len(pl_response["items"]))
+            updated_playlist_video_count += len(pl_response["items"])
+            for item in pl_response['items']:
+                video_id = item['contentDetails']['videoId']
+
+                if playlist.videos.filter(video_id=video_id).count() == 0:  # video DNE in playlist, add it
+                    if (item['snippet']['title'] == "Deleted video" and
+                        item['snippet']['description'] == "This video is unavailable.") or (
+                            item['snippet']['title'] == "Private video" and item['snippet'][
+                        'description'] == "This video is private."):
+                        video = Video(
+                            playlist_item_id=item["id"],
+                            video_id=video_id,
+                            name=item['snippet']['title'],
+                            is_unavailable_on_yt=True,
+                            playlist=playlist,
+                            video_position=item['snippet']['position'] + 1
+                        )
+                    else:
+                        video = Video(
+                            playlist_item_id=item["id"],
+                            video_id=video_id,
+                            published_at=item['contentDetails']['videoPublishedAt'] if 'videoPublishedAt' in
+                                                                                       item[
+                                                                                           'contentDetails'] else None,
+                            name=item['snippet']['title'],
+                            thumbnail_url=getThumbnailURL(item['snippet']['thumbnails']),
+                            channel_id=item['snippet']['channelId'],
+                            channel_name=item['snippet']['channelTitle'],
+                            description=item['snippet']['description'],
+                            video_position=item['snippet']['position'] + 1,
+                            playlist=playlist
+                        )
+
+                    video.video_details_modified = True
+                    video.video_details_modified_at = datetime.datetime.now(tz=pytz.utc)
+                    video.save()
+                    added_videos.append(video)
+                    video_ids.append(video_id)
+                else:  # video found in db
+                    video = playlist.videos.get(video_id=video_id)
+
+                    if video_id in current_video_ids:
+                        video.video_position = item['snippet']['position'] + 1  # update video position to the one on YT
+                        video_ids.append(video_id)
+                        current_video_ids.remove(video_id)
+                    else:
+                        video.is_duplicate = True
+                        playlist.has_duplicate_videos = True
+
+                    # check if the video became unavailable on youtube
+                    if not video.is_unavailable_on_yt:
+                        if (item['snippet']['title'] == "Deleted video" and
+                            item['snippet']['description'] == "This video is unavailable.") or (
+                                item['snippet']['title'] == "Private video" and item['snippet'][
+                            'description'] == "This video is private."):
+                            video.was_deleted_on_yt = True  # video went private on YouTube
+                            video.video_details_modified = True
+                            video.video_details_modified_at = datetime.datetime.now(tz=pytz.utc)
+                            unavailable_videos.append(video)
+
+                    video.save()
+
+            while True:
+                try:
+                    pl_request = youtube.playlistItems().list_next(pl_request, pl_response)
+                    pl_response = pl_request.execute()
+                    updated_playlist_video_count += len(pl_response["items"])
+                    for item in pl_response['items']:
+                        video_id = item['contentDetails']['videoId']
+
+                        if playlist.videos.filter(video_id=video_id).count() == 0:  # video DNE
+                            if (item['snippet']['title'] == "Deleted video" and
+                                item['snippet']['description'] == "This video is unavailable.") or (
+                                    item['snippet']['title'] == "Private video" and item['snippet'][
+                                'description'] == "This video is private."):
+
+                                video = Video(
+                                    playlist_item_id=item["id"],
+                                    video_id=video_id,
+                                    published_at=item['contentDetails'][
+                                        'videoPublishedAt'] if 'videoPublishedAt' in item[
+                                        'contentDetails'] else None,
+                                    name=item['snippet']['title'],
+                                    is_unavailable_on_yt=True,
+                                    playlist=playlist,
+                                    video_position=item['snippet']['position'] + 1
+                                )
+                            else:
+                                video = Video(
+                                    playlist_item_id=item["id"],
+                                    video_id=video_id,
+                                    published_at=item['contentDetails'][
+                                        'videoPublishedAt'] if 'videoPublishedAt' in item[
+                                        'contentDetails'] else None,
+                                    name=item['snippet']['title'],
+                                    thumbnail_url=getThumbnailURL(item['snippet']['thumbnails']),
+                                    channel_id=item['snippet']['channelId'],
+                                    channel_name=item['snippet']['channelTitle'],
+                                    video_position=item['snippet']['position'] + 1,
+                                    playlist=playlist
+                                )
+
+                            video.video_details_modified = True
+                            video.video_details_modified_at = datetime.datetime.now(tz=pytz.utc)
+                            video.save()
+
+                            added_videos.append(video)
+                            video_ids.append(video_id)
+                        else:  # video found in db
+                            video = playlist.videos.get(video_id=video_id)
+
+                            video.video_position = item['snippet']['position'] + 1  # update video position
+
+                            if video_id in current_video_ids:
+                                video.is_duplicate = False
+                                current_video_ids.remove(video_id)
+                            else:
+                                video.is_duplicate = True
+                                playlist.has_duplicate_videos = True
+
+                            # check if the video became unavailable on youtube
+                            if not video.is_unavailable_on_yt:
+                                if (item['snippet']['title'] == "Deleted video" and
+                                    item['snippet']['description'] == "This video is unavailable.") or (
+                                        item['snippet']['title'] == "Private video" and item['snippet'][
+                                    'description'] == "This video is private."):
+                                    video.was_deleted_on_yt = True
+                                    video.video_details_modified = True
+                                    video.video_details_modified_at = datetime.datetime.now(tz=pytz.utc)
+                                    unavailable_videos.append(video)
+                            video.save()
+                except AttributeError:
+                    break
+
+            # API expects the video ids to be a string of comma seperated values, not a python list
+            video_ids_strings = getVideoIdsStrings(video_ids)
+
+            # store duration of all the videos in the playlist
+            vid_durations = []
+
+            for video_ids_string in video_ids_strings:
+                # query the videos resource using API with the string above
+                vid_request = youtube.videos().list(
+                    part="contentDetails,player,snippet,statistics",  # get details of eac video
+                    id=video_ids_string,
+                    maxResults=50
+                )
+
+                vid_response = vid_request.execute()
+
+                for item in vid_response['items']:
+                    duration = item['contentDetails']['duration']
+                    vid = playlist.videos.get(video_id=item['id'])
+
+                    vid.duration = duration.replace("PT", "")
+                    vid.duration_in_seconds = calculateDuration([duration])
+                    vid.has_cc = True if item['contentDetails']['caption'].lower() == 'true' else False
+                    vid.view_count = item['statistics']['viewCount'] if 'viewCount' in item[
+                        'statistics'] else -1
+                    vid.like_count = item['statistics']['likeCount'] if 'likeCount' in item[
+                        'statistics'] else -1
+                    vid.dislike_count = item['statistics']['dislikeCount'] if 'dislikeCount' in item[
+                        'statistics'] else -1
+                    vid.yt_player_HTML = item['player']['embedHtml'] if 'embedHtml' in item['player'] else ''
+                    vid.save()
+
+                    vid_durations.append(duration)
+
+        playlist_duration_in_seconds = calculateDuration(vid_durations)
+
+        playlist.playlist_duration_in_seconds = playlist_duration_in_seconds
+        playlist.playlist_duration = str(timedelta(seconds=playlist_duration_in_seconds))
+
+        if len(video_ids) != len(vid_durations) or len(
+                unavailable_videos) != 0:  # that means some videos in the playlist became private/deleted
+            playlist.has_unavailable_videos = True
+
+        playlist.has_playlist_changed = False
+        playlist.video_count = updated_playlist_video_count
+        playlist.has_new_updates = True
+        playlist.save()
+
+        deleted_videos = current_video_ids  # left out video ids
+
+        return [deleted_videos, unavailable_videos, added_videos]
+
 
 class Playlist(models.Model):
     # playlist details
@@ -683,7 +989,7 @@ class Playlist(models.Model):
     thumbnail_url = models.CharField(max_length=420, blank=True)
     description = models.CharField(max_length=420, default="No description")
     video_count = models.IntegerField(default=0)
-    published_at = models.DateTimeField(blank=True, null=True)
+    published_at = models.DateTimeField(blank=True)
 
     # eg. "<iframe width=\"640\" height=\"360\" src=\"http://www.youtube.com/embed/videoseries?list=PLFuZstFnF1jFwMDffUhV81h0xeff0TXzm\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe>"
     playlist_yt_player_HTML = models.CharField(max_length=420, blank=True)
@@ -711,7 +1017,8 @@ class Playlist(models.Model):
     has_duplicate_videos = models.BooleanField(default=False)  # duplicate videos will not be shown on site
 
     has_playlist_changed = models.BooleanField(default=False)  # determines whether playlist was modified online or not
-    playlist_changed_text = models.CharField(max_length=420, default="")  # user friendly text to display what changed and how much changed
+    playlist_changed_text = models.CharField(max_length=420,
+                                             default="")  # user friendly text to display what changed and how much changed
 
     # for UI
     view_in_grid_mode = models.BooleanField(default=False)  # if False, videso will be showed in a list
@@ -724,11 +1031,17 @@ class Playlist(models.Model):
     created_at = models.DateTimeField(auto_now_add=True)
     updated_at = models.DateTimeField(auto_now=True)
 
+    # for updates
+    last_full_scan_at = models.DateTimeField(auto_now_add=True)
+    has_new_updates = models.BooleanField(default=False)
+
     def __str__(self):
         return "Playlist Len " + str(self.video_count)
 
 
 class Video(models.Model):
+    playlist_item_id = models.CharField(max_length=100)  # the item id of the playlist this video beo
+
     # video details
     video_id = models.CharField(max_length=100)
     name = models.CharField(max_length=100, blank=True)
@@ -766,3 +1079,11 @@ class Video(models.Model):
     num_of_accesses = models.CharField(max_length=69,
                                        default="0")  # tracks num of times this video was clicked on by user
     user_label = models.CharField(max_length=100, default="")  # custom user given name for this video
+
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    # for new videos added/modified/deleted in the playlist
+    video_details_modified = models.BooleanField(
+        default=False)  # is true for videos whose details changed after playlist update
+    video_details_modified_at = models.DateTimeField(auto_now_add=True)  # to set the above false after a day

+ 29 - 0
apps/main/static/svg-loaders/audio.svg

@@ -0,0 +1,29 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="55" height="80" viewBox="0 0 55 80" xmlns="http://www.w3.org/2000/svg" fill="#FFF">
+    <g transform="matrix(1 0 0 -1 0 80)">
+        <rect width="10" height="20" rx="3">
+            <animate attributeName="height"
+                 begin="0s" dur="4.3s"
+                 values="20;45;57;80;64;32;66;45;64;23;66;13;64;56;34;34;2;23;76;79;20" calcMode="linear"
+                 repeatCount="indefinite" />
+        </rect>
+        <rect x="15" width="10" height="80" rx="3">
+            <animate attributeName="height"
+                 begin="0s" dur="2s"
+                 values="80;55;33;5;75;23;73;33;12;14;60;80" calcMode="linear"
+                 repeatCount="indefinite" />
+        </rect>
+        <rect x="30" width="10" height="50" rx="3">
+            <animate attributeName="height"
+                 begin="0s" dur="1.4s"
+                 values="50;34;78;23;56;23;34;76;80;54;21;50" calcMode="linear"
+                 repeatCount="indefinite" />
+        </rect>
+        <rect x="45" width="10" height="30" rx="3">
+            <animate attributeName="height"
+                 begin="0s" dur="2s"
+                 values="30;45;13;80;56;72;45;76;34;23;67;30" calcMode="linear"
+                 repeatCount="indefinite" />
+        </rect>
+    </g>
+</svg>

+ 47 - 0
apps/main/static/svg-loaders/ball-triangle.svg

@@ -0,0 +1,47 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<!-- Todo: add easing -->
+<svg width="57" height="57" viewBox="0 0 57 57" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
+    <g fill="none" fill-rule="evenodd">
+        <g transform="translate(1 1)" stroke-width="2">
+            <circle cx="5" cy="50" r="5">
+                <animate attributeName="cy"
+                     begin="0s" dur="2.2s"
+                     values="50;5;50;50"
+                     calcMode="linear"
+                     repeatCount="indefinite" />
+                <animate attributeName="cx"
+                     begin="0s" dur="2.2s"
+                     values="5;27;49;5"
+                     calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="27" cy="5" r="5">
+                <animate attributeName="cy"
+                     begin="0s" dur="2.2s"
+                     from="5" to="5"
+                     values="5;50;50;5"
+                     calcMode="linear"
+                     repeatCount="indefinite" />
+                <animate attributeName="cx"
+                     begin="0s" dur="2.2s"
+                     from="27" to="27"
+                     values="27;49;5;27"
+                     calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="49" cy="50" r="5">
+                <animate attributeName="cy"
+                     begin="0s" dur="2.2s"
+                     values="50;50;5;50"
+                     calcMode="linear"
+                     repeatCount="indefinite" />
+                <animate attributeName="cx"
+                     from="49" to="49"
+                     begin="0s" dur="2.2s"
+                     values="49;5;27;49"
+                     calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+        </g>
+    </g>
+</svg>

+ 52 - 0
apps/main/static/svg-loaders/bars.svg

@@ -0,0 +1,52 @@
+<svg width="135" height="140" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#fff">
+    <rect y="10" width="15" height="120" rx="6">
+        <animate attributeName="height"
+             begin="0.5s" dur="1s"
+             values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
+             repeatCount="indefinite" />
+        <animate attributeName="y"
+             begin="0.5s" dur="1s"
+             values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
+             repeatCount="indefinite" />
+    </rect>
+    <rect x="30" y="10" width="15" height="120" rx="6">
+        <animate attributeName="height"
+             begin="0.25s" dur="1s"
+             values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
+             repeatCount="indefinite" />
+        <animate attributeName="y"
+             begin="0.25s" dur="1s"
+             values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
+             repeatCount="indefinite" />
+    </rect>
+    <rect x="60" width="15" height="140" rx="6">
+        <animate attributeName="height"
+             begin="0s" dur="1s"
+             values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
+             repeatCount="indefinite" />
+        <animate attributeName="y"
+             begin="0s" dur="1s"
+             values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
+             repeatCount="indefinite" />
+    </rect>
+    <rect x="90" y="10" width="15" height="120" rx="6">
+        <animate attributeName="height"
+             begin="0.25s" dur="1s"
+             values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
+             repeatCount="indefinite" />
+        <animate attributeName="y"
+             begin="0.25s" dur="1s"
+             values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
+             repeatCount="indefinite" />
+    </rect>
+    <rect x="120" y="10" width="15" height="120" rx="6">
+        <animate attributeName="height"
+             begin="0.5s" dur="1s"
+             values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear"
+             repeatCount="indefinite" />
+        <animate attributeName="y"
+             begin="0.5s" dur="1s"
+             values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear"
+             repeatCount="indefinite" />
+    </rect>
+</svg>

+ 20 - 0
apps/main/static/svg-loaders/circles.svg

@@ -0,0 +1,20 @@
+<svg width="135" height="135" viewBox="0 0 135 135" xmlns="http://www.w3.org/2000/svg" fill="#fff">
+    <path d="M67.447 58c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10zm9.448 9.447c0 5.523 4.477 10 10 10 5.522 0 10-4.477 10-10s-4.478-10-10-10c-5.523 0-10 4.477-10 10zm-9.448 9.448c-5.523 0-10 4.477-10 10 0 5.522 4.477 10 10 10s10-4.478 10-10c0-5.523-4.477-10-10-10zM58 67.447c0-5.523-4.477-10-10-10s-10 4.477-10 10 4.477 10 10 10 10-4.477 10-10z">
+        <animateTransform
+            attributeName="transform"
+            type="rotate"
+            from="0 67 67"
+            to="-360 67 67"
+            dur="2.5s"
+            repeatCount="indefinite"/>
+    </path>
+    <path d="M28.19 40.31c6.627 0 12-5.374 12-12 0-6.628-5.373-12-12-12-6.628 0-12 5.372-12 12 0 6.626 5.372 12 12 12zm30.72-19.825c4.686 4.687 12.284 4.687 16.97 0 4.686-4.686 4.686-12.284 0-16.97-4.686-4.687-12.284-4.687-16.97 0-4.687 4.686-4.687 12.284 0 16.97zm35.74 7.705c0 6.627 5.37 12 12 12 6.626 0 12-5.373 12-12 0-6.628-5.374-12-12-12-6.63 0-12 5.372-12 12zm19.822 30.72c-4.686 4.686-4.686 12.284 0 16.97 4.687 4.686 12.285 4.686 16.97 0 4.687-4.686 4.687-12.284 0-16.97-4.685-4.687-12.283-4.687-16.97 0zm-7.704 35.74c-6.627 0-12 5.37-12 12 0 6.626 5.373 12 12 12s12-5.374 12-12c0-6.63-5.373-12-12-12zm-30.72 19.822c-4.686-4.686-12.284-4.686-16.97 0-4.686 4.687-4.686 12.285 0 16.97 4.686 4.687 12.284 4.687 16.97 0 4.687-4.685 4.687-12.283 0-16.97zm-35.74-7.704c0-6.627-5.372-12-12-12-6.626 0-12 5.373-12 12s5.374 12 12 12c6.628 0 12-5.373 12-12zm-19.823-30.72c4.687-4.686 4.687-12.284 0-16.97-4.686-4.686-12.284-4.686-16.97 0-4.687 4.686-4.687 12.284 0 16.97 4.686 4.687 12.284 4.687 16.97 0z">
+        <animateTransform
+            attributeName="transform"
+            type="rotate"
+            from="0 67 67"
+            to="360 67 67"
+            dur="8s"
+            repeatCount="indefinite"/>
+    </path>
+</svg>

+ 56 - 0
apps/main/static/svg-loaders/grid.svg

@@ -0,0 +1,56 @@
+<svg width="105" height="105" viewBox="0 0 105 105" xmlns="http://www.w3.org/2000/svg" fill="#fff">
+    <circle cx="12.5" cy="12.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="0s" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="12.5" cy="52.5" r="12.5" fill-opacity=".5">
+        <animate attributeName="fill-opacity"
+         begin="100ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="52.5" cy="12.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="300ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="52.5" cy="52.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="600ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="92.5" cy="12.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="800ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="92.5" cy="52.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="400ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="12.5" cy="92.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="700ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="52.5" cy="92.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="500ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+    <circle cx="92.5" cy="92.5" r="12.5">
+        <animate attributeName="fill-opacity"
+         begin="200ms" dur="1s"
+         values="1;.2;1" calcMode="linear"
+         repeatCount="indefinite" />
+    </circle>
+</svg>

+ 18 - 0
apps/main/static/svg-loaders/hearts.svg

@@ -0,0 +1,18 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="140" height="64" viewBox="0 0 140 64" xmlns="http://www.w3.org/2000/svg" fill="#fff">
+    <path d="M30.262 57.02L7.195 40.723c-5.84-3.976-7.56-12.06-3.842-18.063 3.715-6 11.467-7.65 17.306-3.68l4.52 3.76 2.6-5.274c3.717-6.002 11.47-7.65 17.305-3.68 5.84 3.97 7.56 12.054 3.842 18.062L34.49 56.118c-.897 1.512-2.793 1.915-4.228.9z" fill-opacity=".5">
+        <animate attributeName="fill-opacity"
+             begin="0s" dur="1.4s"
+             values="0.5;1;0.5"
+             calcMode="linear"
+             repeatCount="indefinite" />
+    </path>
+    <path d="M105.512 56.12l-14.44-24.272c-3.716-6.008-1.996-14.093 3.843-18.062 5.835-3.97 13.588-2.322 17.306 3.68l2.6 5.274 4.52-3.76c5.84-3.97 13.592-2.32 17.307 3.68 3.718 6.003 1.998 14.088-3.842 18.064L109.74 57.02c-1.434 1.014-3.33.61-4.228-.9z" fill-opacity=".5">
+        <animate attributeName="fill-opacity"
+             begin="0.7s" dur="1.4s"
+             values="0.5;1;0.5"
+             calcMode="linear"
+             repeatCount="indefinite" />
+    </path>
+    <path d="M67.408 57.834l-23.01-24.98c-5.864-6.15-5.864-16.108 0-22.248 5.86-6.14 15.37-6.14 21.234 0L70 16.168l4.368-5.562c5.863-6.14 15.375-6.14 21.235 0 5.863 6.14 5.863 16.098 0 22.247l-23.007 24.98c-1.43 1.556-3.757 1.556-5.188 0z" />
+</svg>

+ 17 - 0
apps/main/static/svg-loaders/oval.svg

@@ -0,0 +1,17 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
+    <g fill="none" fill-rule="evenodd">
+        <g transform="translate(1 1)" stroke-width="2">
+            <circle stroke-opacity=".5" cx="18" cy="18" r="18"/>
+            <path d="M36 18c0-9.94-8.06-18-18-18">
+                <animateTransform
+                    attributeName="transform"
+                    type="rotate"
+                    from="0 18 18"
+                    to="360 18 18"
+                    dur="1s"
+                    repeatCount="indefinite"/>
+            </path>
+        </g>
+    </g>
+</svg>

+ 37 - 0
apps/main/static/svg-loaders/puff.svg

@@ -0,0 +1,37 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
+    <g fill="none" fill-rule="evenodd" stroke-width="2">
+        <circle cx="22" cy="22" r="1">
+            <animate attributeName="r"
+                begin="0s" dur="1.8s"
+                values="1; 20"
+                calcMode="spline"
+                keyTimes="0; 1"
+                keySplines="0.165, 0.84, 0.44, 1"
+                repeatCount="indefinite" />
+            <animate attributeName="stroke-opacity"
+                begin="0s" dur="1.8s"
+                values="1; 0"
+                calcMode="spline"
+                keyTimes="0; 1"
+                keySplines="0.3, 0.61, 0.355, 1"
+                repeatCount="indefinite" />
+        </circle>
+        <circle cx="22" cy="22" r="1">
+            <animate attributeName="r"
+                begin="-0.9s" dur="1.8s"
+                values="1; 20"
+                calcMode="spline"
+                keyTimes="0; 1"
+                keySplines="0.165, 0.84, 0.44, 1"
+                repeatCount="indefinite" />
+            <animate attributeName="stroke-opacity"
+                begin="-0.9s" dur="1.8s"
+                values="1; 0"
+                calcMode="spline"
+                keyTimes="0; 1"
+                keySplines="0.3, 0.61, 0.355, 1"
+                repeatCount="indefinite" />
+        </circle>
+    </g>
+</svg>

+ 42 - 0
apps/main/static/svg-loaders/rings.svg

@@ -0,0 +1,42 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="45" height="45" viewBox="0 0 45 45" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
+    <g fill="none" fill-rule="evenodd" transform="translate(1 1)" stroke-width="2">
+        <circle cx="22" cy="22" r="6" stroke-opacity="0">
+            <animate attributeName="r"
+                 begin="1.5s" dur="3s"
+                 values="6;22"
+                 calcMode="linear"
+                 repeatCount="indefinite" />
+            <animate attributeName="stroke-opacity"
+                 begin="1.5s" dur="3s"
+                 values="1;0" calcMode="linear"
+                 repeatCount="indefinite" />
+            <animate attributeName="stroke-width"
+                 begin="1.5s" dur="3s"
+                 values="2;0" calcMode="linear"
+                 repeatCount="indefinite" />
+        </circle>
+        <circle cx="22" cy="22" r="6" stroke-opacity="0">
+            <animate attributeName="r"
+                 begin="3s" dur="3s"
+                 values="6;22"
+                 calcMode="linear"
+                 repeatCount="indefinite" />
+            <animate attributeName="stroke-opacity"
+                 begin="3s" dur="3s"
+                 values="1;0" calcMode="linear"
+                 repeatCount="indefinite" />
+            <animate attributeName="stroke-width"
+                 begin="3s" dur="3s"
+                 values="2;0" calcMode="linear"
+                 repeatCount="indefinite" />
+        </circle>
+        <circle cx="22" cy="22" r="8">
+            <animate attributeName="r"
+                 begin="0s" dur="1.5s"
+                 values="6;1;2;3;4;5;6"
+                 calcMode="linear"
+                 repeatCount="indefinite" />
+        </circle>
+    </g>
+</svg>

+ 55 - 0
apps/main/static/svg-loaders/spinning-circles.svg

@@ -0,0 +1,55 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="58" height="58" viewBox="0 0 58 58" xmlns="http://www.w3.org/2000/svg">
+    <g fill="none" fill-rule="evenodd">
+        <g transform="translate(2 1)" stroke="#FFF" stroke-width="1.5">
+            <circle cx="42.601" cy="11.462" r="5" fill-opacity="1" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="1;0;0;0;0;0;0;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="49.063" cy="27.063" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;1;0;0;0;0;0;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="42.601" cy="42.663" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;0;1;0;0;0;0;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="27" cy="49.125" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;0;0;1;0;0;0;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="11.399" cy="42.663" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;0;0;0;1;0;0;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="4.938" cy="27.063" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;0;0;0;0;1;0;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="11.399" cy="11.462" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;0;0;0;0;0;1;0" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+            <circle cx="27" cy="5" r="5" fill-opacity="0" fill="#fff">
+                <animate attributeName="fill-opacity"
+                     begin="0s" dur="1.3s"
+                     values="0;0;0;0;0;0;0;1" calcMode="linear"
+                     repeatCount="indefinite" />
+            </circle>
+        </g>
+    </g>
+</svg>

+ 32 - 0
apps/main/static/svg-loaders/tail-spin.svg

@@ -0,0 +1,32 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
+    <defs>
+        <linearGradient x1="8.042%" y1="0%" x2="65.682%" y2="23.865%" id="a">
+            <stop stop-color="#fff" stop-opacity="0" offset="0%"/>
+            <stop stop-color="#fff" stop-opacity=".631" offset="63.146%"/>
+            <stop stop-color="#fff" offset="100%"/>
+        </linearGradient>
+    </defs>
+    <g fill="none" fill-rule="evenodd">
+        <g transform="translate(1 1)">
+            <path d="M36 18c0-9.94-8.06-18-18-18" id="Oval-2" stroke="url(#a)" stroke-width="2">
+                <animateTransform
+                    attributeName="transform"
+                    type="rotate"
+                    from="0 18 18"
+                    to="360 18 18"
+                    dur="0.9s"
+                    repeatCount="indefinite" />
+            </path>
+            <circle fill="#fff" cx="36" cy="18" r="1">
+                <animateTransform
+                    attributeName="transform"
+                    type="rotate"
+                    from="0 18 18"
+                    to="360 18 18"
+                    dur="0.9s"
+                    repeatCount="indefinite" />
+            </circle>
+        </g>
+    </g>
+</svg>

+ 33 - 0
apps/main/static/svg-loaders/three-dots.svg

@@ -0,0 +1,33 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="120" height="30" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#fff">
+    <circle cx="15" cy="15" r="15">
+        <animate attributeName="r" from="15" to="15"
+                 begin="0s" dur="0.8s"
+                 values="15;9;15" calcMode="linear"
+                 repeatCount="indefinite" />
+        <animate attributeName="fill-opacity" from="1" to="1"
+                 begin="0s" dur="0.8s"
+                 values="1;.5;1" calcMode="linear"
+                 repeatCount="indefinite" />
+    </circle>
+    <circle cx="60" cy="15" r="9" fill-opacity="0.3">
+        <animate attributeName="r" from="9" to="9"
+                 begin="0s" dur="0.8s"
+                 values="9;15;9" calcMode="linear"
+                 repeatCount="indefinite" />
+        <animate attributeName="fill-opacity" from="0.5" to="0.5"
+                 begin="0s" dur="0.8s"
+                 values=".5;1;.5" calcMode="linear"
+                 repeatCount="indefinite" />
+    </circle>
+    <circle cx="105" cy="15" r="15">
+        <animate attributeName="r" from="15" to="15"
+                 begin="0s" dur="0.8s"
+                 values="15;9;15" calcMode="linear"
+                 repeatCount="indefinite" />
+        <animate attributeName="fill-opacity" from="1" to="1"
+                 begin="0s" dur="0.8s"
+                 values="1;.5;1" calcMode="linear"
+                 repeatCount="indefinite" />
+    </circle>
+</svg>

+ 38 - 0
apps/main/templates/intercooler/manage_playlists_create.html

@@ -0,0 +1,38 @@
+<br>
+<hr>
+<br>
+
+<div class="container-fluid">
+<h2>Enter a Playlist link or list multiple Playlist links line by line</h2>
+<br>
+    <div id="import-playlists-from">
+      <textarea name="import-playlist-textarea" class="form-control bg-dark text-white" id="video-notes-text-area" placeholder="Enter here" rows="5"
+                hx-post="{% url 'manage_save' 'manage_playlists_import_textarea' %}"
+                hx-trigger="keyup changed delay:500ms"
+                hx-indicator="#spinner">{{ manage_playlists_import_textarea }}</textarea>
+        <!--
+        <input class="form-check-input mx-3 big-checkbox" type="checkbox" name="e" id="checkbox"> <br>
+        <input class="form-check-input mx-3 big-checkbox" type="checkbox" name="f" id="checkbox"> <br>
+        <input class="form-check-input mx-3 big-checkbox" type="checkbox" name="g" id="checkbox"> <br>
+        <input class="form-check-input mx-3 big-checkbox" type="checkbox" name="h" id="checkbox"> <br>
+        -->
+    </div>
+      <br>
+      <div class="d-flex justify-content-start">
+          <button type="button" hx-post="{% url 'manage_import_playlists' %}" hx-include="[id='import-playlists-from']" hx-target="#import-playlists-results" hx-indicator="#spinner" class="btn btn-success">Import!</button>
+
+            <div id="spinner" class="htmx-indicator ms-3 mt-2">
+                <div class="spinner-border text-light" role="status">
+                </div>
+            </div>
+      </div>
+      <br>
+    <div id="import-playlists-results">
+
+    </div>
+
+
+  <br>
+
+
+  </div>

+ 1 - 1
apps/main/templates/intercooler/manage_playlists_import.html

@@ -2,7 +2,7 @@
 <hr>
 <br>
 
-  <div class="container-fluid">
+<div class="container-fluid">
 <h2>Enter a Playlist link or list multiple Playlist links line by line</h2>
 <br>
     <div id="import-playlists-from">

+ 1 - 1
apps/main/templates/intercooler/progress_bar.html

@@ -22,7 +22,7 @@
     {% else %}
     <div class="d-flex justify-content-center pt-3 pb-2 mb-3" style="background-color: #5fa075">
 
-        <div class="w-75">
+        <div class="w-50">
             <div class="d-flex justify-content-center">
                     <h3>Finished importing all of your playlists from YouTube</h3>
                 </div>

+ 210 - 0
apps/main/templates/intercooler/updated_playlist.html

@@ -0,0 +1,210 @@
+
+{% load humanize %}
+{% load static %}
+
+    <div class="alert alert-success alert-dismissible fade show" role="alert">
+        {{ playlist_changed_text|linebreaksbr }}
+
+        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+    </div>
+
+    <div class="list-group-item list-group-item-action active">
+        <div class="d-flex w-100 justify-content-between">
+            <span>
+                <h2 class="mb-1">{{ playlist.name }} <small><a><i class="fas fa-pen-square"></i></a></small> </h2>
+                <h6>by {{ playlist.channel_name }}</h6>
+            </span>
+            <h4>
+                <span id="notice-div">
+                    {% if playlist.marked_as != "none" %}
+                    <span class="badge bg-success text-white" >{{ playlist.marked_as|title }}</span>
+                    {% endif %}
+                </span>
+            </h4>
+        </div>
+        <p class="mb-1">
+            {% if playlist.description %}
+            <h5>{{ playlist.description }}</h5>
+            {% else %}
+            <h5>No description</h5>
+            {% endif %}
+        </p>
+        <small>
+            <span class="badge bg-dark rounded-pill">{{ playlist.video_count }} videos</span>
+            <span class="badge bg-dark rounded-pill">{{ playlist.playlist_duration }} </span>
+            {% if playlist.is_private_on_yt %}<span class="badge bg-dark rounded-pill">private</span>{% endif %}
+            {% if playlist.has_unavailable_videos %}
+                    <span class="badge bg-dark rounded-pill">some videos are unavailable</span>
+            {% endif %}
+            {% if playlist.has_duplicate_videos %}
+                    <span class="badge bg-dark rounded-pill">duplicate videos</span>
+            {% endif %}
+        </small>
+    </div>
+
+    <br>
+
+    <div id="row1">
+        <div class="d-flex bd-highlight mb-1">
+            <div class="me-auto bd-highlight">
+                <div class="btn-toolbar mb-2 mb-md-0">
+                    <div class="btn-group me-2">
+                        <button type="button" class="btn {% if playlist.view_in_grid_mode %}btn-info {% else %}btn-outline-info{% endif %}">Grid</button>
+                        <button type="button" class="btn {% if not playlist.view_in_grid_mode %}btn-info {% else %}btn-outline-info{% endif %}">List</button>
+                    </div>
+                    <div class="btn-group me-2">
+                        <button type="button" class="btn btn-outline-success dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                            Sort By
+                        </button>
+                        <ul class="dropdown-menu">
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'popularity' %}" hx-trigger="click" hx-target="#videos-div">Popularity</button></li>
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'date-published' %}" hx-trigger="click" hx-target="#videos-div">Date Published</button></li>
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'views' %}" hx-trigger="click" hx-target="#videos-div">Views</button></li>
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'has-cc' %}" hx-trigger="click" hx-target="#videos-div">Has CC</button></li>
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'duration' %}" hx-trigger="click" hx-target="#videos-div">Duration</button></li>
+                            <li><hr class="dropdown-divider"></li>
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'new-updates' %}" hx-trigger="click" hx-target="#videos-div">Updates</button></li>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+
+            <div class="bd-highlight">
+                <div class="btn-toolbar mb-2 mb-md-0">
+
+                        <div class="btn-group me-2">
+                            <button type="button" class="btn btn-outline-warning dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                                Mark As
+                            </button>
+                            <ul class="dropdown-menu">
+                                <li><button class="dropdown-item" hx-get="{% url 'mark_playlist_as' playlist.playlist_id 'none' %}" hx-trigger="click" hx-target="#notice-div">None</button></li>
+                                <li><button class="dropdown-item" hx-get="{% url 'mark_playlist_as' playlist.playlist_id 'watching' %}" hx-trigger="click" hx-target="#notice-div">Watching</button></li>
+                                <li><button class="dropdown-item" hx-get="{% url 'mark_playlist_as' playlist.playlist_id 'plan-to-watch' %}" hx-trigger="click" hx-target="#notice-div">Plan to Watch</button></li>
+                            </ul>
+                        </div>
+
+                        <div class="btn-group me-2">
+                            <button type="button" class="btn btn-outline-light" onclick="row1_hide()">Manage</button>
+                        </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div id="row2" style="display: none">
+        <div class="d-flex bd-highlight mb-1">
+            <div class="me-auto bd-highlight">
+                <div class="btn-toolbar mb-2 mb-md-0">
+                    <div id="select-all-btn">
+                        <div class="btn-group me-2">
+                        <button type="button" class="btn btn-outline-info" id="select-all-btn">Select All</button>
+                    </div>
+                    </div>
+                    <div id="deselect-all-btn" style="display: none">
+                        <div class="btn-group me-2">
+                        <button type="button" class="btn btn-outline-info" id="select-all-btn">De-select All</button>
+                    </div>
+                    </div>
+                    <div class="btn-group me-2">
+                        <button type="submit" form="my-form" class="btn btn-outline-success" data-bs-toggle="dropdown" aria-expanded="false">
+                            Move
+                        </button>
+                    </div>
+                    <div class="btn-group me-2">
+                        <button type="button" class="btn btn-outline-primary" data-bs-toggle="dropdown" aria-expanded="false">
+                            UnTube These
+                        </button>
+                    </div>
+                    <div class="btn-group me-2">
+                        <button type="button" hx-post="{% url 'delete_videos' %}" hx-include="[id='video-checkboxes']" class="btn btn-outline-danger" data-bs-toggle="dropdown" aria-expanded="false">
+                            Delete
+                        </button>
+                    </div>
+                </div>
+            </div>
+
+
+
+            <div class="bd-highlight">
+            <div class="btn-toolbar mb-2 mb-md-0">
+
+                    <div class="btn-group me-2">
+                        <button type="button" class="btn btn-light" onclick="row1_show()">Manage</button>
+                    </div>
+
+                </div>
+        </div>
+
+        </div>
+    </div>
+    <br>
+    <div class="table-responsive" id="videos-div">
+        <div class="list-group" id="video-checkboxes">
+          {% for video in videos %}
+
+            <li class="list-group-item d-flex justify-content-between align-items-center" style="background-color: #40B3A2">
+
+        {% if video.is_unavailable_on_yt %}
+
+            <div class="d-flex justify-content-between align-items-center">
+                <div>
+                    <input class="video-checkboxes" style="display: none" type="checkbox" value="{{ video.video_id }}" name="video-id">
+                </div>
+                  <div class="ms-4" style="max-width: 115px; max-height: 100px;">
+                      <img src="https://i.ytimg.com/vi/9219YrnwDXE/maxresdefault.jpg" class="img-fluid" alt="">
+                  </div>
+                    <div class="ms-4">
+                        <a class="link-dark" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" target="_blank">
+                            {{ video.video_position }}. {{ video.name }}
+                        </a>
+                        <br><br>
+                    </div>
+            </div>
+          {% else %}
+
+            <div class="d-flex justify-content-between align-items-center" >
+                <div>
+                    <input class="video-checkboxes" style="display: none" type="checkbox" value="{{ video.video_id }}" name="video-id">
+                </div>
+                  <div class="ms-4" style="max-width: 115px; max-height: 100px;">
+                      <img src="{% if video.thumbnail_url %}{{ video.thumbnail_url }}{% else %}https://i.ytimg.com/vi/9219YrnwDXE/maxresdefault.jpg{% endif %}" class="img-fluid" alt="">
+                  </div>
+                    <div class="ms-4">
+                        <a class="link-dark" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" target="_blank">{{ video.video_position }}. {{ video.name }}</a> <br>
+                        <span class="badge bg-secondary">{{ video.duration }}</span>
+                        {% if video.has_cc %}<span class="badge bg-secondary">CC</span>{% endif %}
+                        {% if video.published_at %}<span class="badge bg-secondary">{{ video.published_at }}</span>{% endif %}
+                        {% if video.view_count %}<span class="badge bg-info">{{ video.view_count|intword|intcomma }} views</span>{% endif %}
+                        {% if video.is_duplicate %}<span class="badge bg-primary">duplicate</span>{% endif %}
+                        {% if video.video_details_modified %}<span class="badge bg-danger">UPDATED - {% if video.was_deleted_on_yt %}WENT PRIVATE/DELETED{% else %}NEWLY ADDED{% endif %}</span>{% endif %}<br>
+                    </div>
+            </div>
+            <div class="ms-5">
+                <a class="btn btn-sm btn-info mb-1" type="button" href="https://www.youtube.com/watch?v={{ video.video_id }}" class="btn btn-info me-1" target="_blank"><i class="fas fa-external-link-alt" aria-hidden="true"></i></a>
+                <input class="form-control me-1 visually-hidden" id="video-{{ video.video_id }}" value="https://www.youtube.com/watch?v={{ video.video_id }}">
+                <button class="copy-btn btn btn-success mb-1" data-clipboard-target="#video-{{ video.video_id }}">
+                    <i class="far fa-copy" aria-hidden="true"></i>
+                </button>
+                <button class="btn btn-sm btn-primary mb-1" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasWithBackdrop" aria-controls="offcanvasBottom" hx-get="{% url 'video_details' playlist.playlist_id video.video_id %}" hx-trigger="click" hx-target="#video-details">Details</button>
+                <button class="btn btn-sm btn-warning mb-1" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasForVideoNotes" aria-controls="offcanvasBottom" hx-get="{% url 'video_notes' playlist.playlist_id video.video_id %}" hx-trigger="click" hx-target="#video-notes">Notes</button>
+                <button class="btn btn-sm btn-dark mb-1" type="button" hx-get="{% url 'mark_video_favorite' playlist.playlist_id video.video_id %}" hx-target="#video-{{ forloop.counter }}-fav">
+                    <div id="video-{{ forloop.counter }}-fav">
+                        {% if video.is_favorite %}
+                            <i class="fas fa-heart"></i>
+                        {% else %}
+                            <i class="far fa-heart"></i>
+                        {% endif %}
+                    </div>
+                </button>
+                    {% if playlist.marked_as == "watching" %}
+                                        <button class="btn btn-sm btn-light mb-1" type="button">
+
+                        <i class="far fa-check-circle"></i>
+                                        </button>
+
+                    {% endif %}
+            </div>
+          {% endif %}
+
+            </li>
+
+        {% endfor %}

+ 69 - 31
apps/main/templates/intercooler/videos.html

@@ -2,39 +2,77 @@
 
 
 <div class="list-group" id="video-checkboxes">
-  {% for video in videos %}
-      <li class="list-group-item d-flex justify-content-between align-items-center">
-
-  {% if video.is_unavailable_on_yt %}
-    <a style="background-color: #E73927;" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" class="list-group-item list-group-item-dark">
-            {{ video.video_position }}. {{ video.name }}
-        <span class="badge bg-secondary">{{ video.duration }}</span>
-    </a>
-  {% else %}
-            <div class="d-flex justify-content-between align-items-center">
-                <div>
-                    <input class="video-checkboxes" style="display: none" type="checkbox" value="{{ video.video_id }}" name="video-id">
+    {% if videos %}
+      {% for video in videos %}
+                <li class="list-group-item d-flex justify-content-between align-items-center" style="background-color: #40B3A2">
+
+            {% if video.is_unavailable_on_yt %}
+
+                <div class="d-flex justify-content-between align-items-center">
+                    <div>
+                        <input class="video-checkboxes" style="display: none" type="checkbox" value="{{ video.video_id }}" name="video-id">
+                    </div>
+                      <div class="ms-4" style="max-width: 115px; max-height: 100px;">
+                          <img src="https://i.ytimg.com/vi/9219YrnwDXE/maxresdefault.jpg" class="img-fluid" alt="">
+                      </div>
+                        <div class="ms-4">
+                            <a class="link-dark" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" target="_blank">
+                                {{ video.video_position }}. {{ video.name }}
+                            </a>
+                            <br><br>
+                        </div>
                 </div>
-                  <div class="ms-4" style="max-width: 115px; max-height: 100px;">
-                      <img src="{% if video.thumbnail_url %}{{ video.thumbnail_url }}{% else %}https://i.ytimg.com/vi/9219YrnwDXE/maxresdefault.jpg{% endif %}" class="img-fluid" alt="">
-                  </div>
-                    <div class="ms-4">
-                        <a href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" target="_blank">{{ video.video_position }}. {{ video.name }}</a> <br>
-                        <span class="badge bg-secondary">{{ video.duration }}</span>
-                        {% if video.has_cc %}<span class="badge bg-secondary">CC</span>{% endif %}
-                        {% if video.published_at %}<span class="badge bg-secondary">{{ video.published_at }}</span>{% endif %}
-                      {% if video.view_count %}<span class="badge bg-info">{{ video.view_count|intword|intcomma }} views</span>{% endif %}
-
-                        {% if video.is_duplicate %}<span class="badge bg-primary">duplicate</span>{% endif %}<br>
+              {% else %}
+
+                <div class="d-flex justify-content-between align-items-center" >
+                    <div>
+                        <input class="video-checkboxes" style="display: none" type="checkbox" value="{{ video.video_id }}" name="video-id">
                     </div>
-            </div>
-                          <div>
-                          <a class="btn btn-sm btn-success" type="button" target="_blank" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}">Open</a>
+                      <div class="ms-4" style="max-width: 115px; max-height: 100px;">
+                          <img src="{% if video.thumbnail_url %}{{ video.thumbnail_url }}{% else %}https://i.ytimg.com/vi/9219YrnwDXE/maxresdefault.jpg{% endif %}" class="img-fluid" alt="">
+                      </div>
+                        <div class="ms-4">
+                            <a class="link-dark" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" target="_blank">{{ video.video_position }}. {{ video.name }}</a> <br>
+                            <span class="badge bg-secondary">{{ video.duration }}</span>
+                            {% if video.has_cc %}<span class="badge bg-secondary">CC</span>{% endif %}
+                            {% if video.published_at %}<span class="badge bg-secondary">{{ video.published_at }}</span>{% endif %}
+                          {% if video.view_count %}<span class="badge bg-info">{{ video.view_count|intword|intcomma }} views</span>{% endif %}
 
-                        <button class="btn btn-sm btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasWithBackdrop" aria-controls="offcanvasBottom" hx-get="{% url 'video_details' playlist.playlist_id video.video_id %}" hx-trigger="click" hx-target="#video-details">Details</button>
-                          </div>
+                            {% if video.is_duplicate %}<span class="badge bg-primary">duplicate</span>{% endif %}
+                            {% if video.video_details_modified %}<span class="badge bg-danger">UPDATED - {% if video.was_deleted_on_yt %}WENT PRIVATE/DELETED{% else %}NEWLY ADDED{% endif %}</span>{% endif %}<br>
+                            <br>
+                        </div>
+                </div>
+                <div class="ms-5">
+                    <a class="btn btn-sm btn-info mb-1" type="button" href="https://www.youtube.com/watch?v={{ video.video_id }}" class="btn btn-info me-1" target="_blank"><i class="fas fa-external-link-alt" aria-hidden="true"></i></a>
+                    <input class="form-control me-1 visually-hidden" id="video-{{ video.video_id }}" value="https://www.youtube.com/watch?v={{ video.video_id }}">
+                    <button class="copy-btn btn btn-success mb-1" data-clipboard-target="#video-{{ video.video_id }}">
+                        <i class="far fa-copy" aria-hidden="true"></i>
+                    </button>
+                    <button class="btn btn-sm btn-primary mb-1" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasWithBackdrop" aria-controls="offcanvasBottom" hx-get="{% url 'video_details' playlist.playlist_id video.video_id %}" hx-trigger="click" hx-target="#video-details">Details</button>
+                    <button class="btn btn-sm btn-warning mb-1" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasForVideoNotes" aria-controls="offcanvasBottom" hx-get="{% url 'video_notes' playlist.playlist_id video.video_id %}" hx-trigger="click" hx-target="#video-notes">Notes</button>
+                    <button class="btn btn-sm btn-dark mb-1" type="button" hx-get="{% url 'mark_video_favorite' playlist.playlist_id video.video_id %}" hx-target="#video-{{ forloop.counter }}-fav">
+                        <div id="video-{{ forloop.counter }}-fav">
+                            {% if video.is_favorite %}
+                                <i class="fas fa-heart"></i>
+                            {% else %}
+                                <i class="far fa-heart"></i>
+                            {% endif %}
+                        </div>
+                    </button>
+                        {% if playlist.marked_as == "watching" %}
+                                            <button class="btn btn-sm btn-light mb-1" type="button">
+
+                            <i class="far fa-check-circle"></i>
+                                            </button>
+
+                        {% endif %}
+                </div>
+              {% endif %}
 
-  {% endif %}
-  {% endfor %}
-</li>
+                </li>
+      {% endfor %}
+    {% else %}
+        <h3>{{ display_text }}</h3>
+    {% endif %}
 </div>

+ 34 - 40
apps/main/templates/view_playlist.html

@@ -1,20 +1,27 @@
 
 {% extends 'base.html' %}
 {% load humanize %}
+{% load static %}
 
 {% block content %}
 
+
+
     {% if playlist.has_playlist_changed %}
-    <div class="alert alert-success alert-dismissible fade show" role="alert">
-        {{ playlist.playlist_changed_text }} <a href="#" class="link-success">Update!</a>
-        <a href="#" class="link-primary">Why aren't you doing this automatically?</a>
 
-        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
-    </div>
+        <div hx-get="{% url 'update_playlist' playlist.playlist_id %}" hx-trigger="load" hx-swap="outerHTML">
+            <div class="alert alert-success alert-dismissible fade show" role="alert">
+                {{ playlist.playlist_changed_text|linebreaksbr|default:"Looks like the playlist on YouTube was modified!" }}
+
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+            </div>
+            <div class="d-flex justify-content-center mt-4 mb-3" id="loading-sign">
+                <img src="{% static 'svg-loaders/circles.svg' %}" width="40" height="40">
+                <h5 class="mt-2 ms-2">Updating playlist '{{ playlist.name }}', please wait!</h5>
+            </div>
+        </div>
     {% else %}
     <br>
-    {% endif %}
-
 
     <div class="list-group-item list-group-item-action active">
         <div class="d-flex w-100 justify-content-between">
@@ -70,6 +77,8 @@
                             <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'views' %}" hx-trigger="click" hx-target="#videos-div">Views</button></li>
                             <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'has-cc' %}" hx-trigger="click" hx-target="#videos-div">Has CC</button></li>
                             <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'duration' %}" hx-trigger="click" hx-target="#videos-div">Duration</button></li>
+                            <li><hr class="dropdown-divider"></li>
+                            <li><button class="dropdown-item" hx-get="{% url 'order_playlist_by' playlist.playlist_id 'new-updates' %}" hx-trigger="click" hx-target="#videos-div">Updates</button></li>
                         </ul>
                     </div>
                 </div>
@@ -78,6 +87,19 @@
             <div class="bd-highlight">
                 <div class="btn-toolbar mb-2 mb-md-0">
 
+                        <div class="btn-group me-2">
+                            <button type="button" class="btn btn-secondary">
+                                <i class="fas fa-sync"></i>
+                            </button>
+                        </div>
+
+                        <div class="btn-group me-2">
+                            <button type="button" class="btn btn-danger">
+                                <i class="fas fa-dumpster-fire"></i>
+                            </button>
+                        </div>
+
+
                         <div class="btn-group me-2">
                             <button type="button" class="btn btn-outline-warning dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
                                 Mark As
@@ -181,7 +203,9 @@
                         {% if video.published_at %}<span class="badge bg-secondary">{{ video.published_at }}</span>{% endif %}
                       {% if video.view_count %}<span class="badge bg-info">{{ video.view_count|intword|intcomma }} views</span>{% endif %}
 
-                        {% if video.is_duplicate %}<span class="badge bg-primary">duplicate</span>{% endif %}<br>
+                        {% if video.is_duplicate %}<span class="badge bg-primary">duplicate</span>{% endif %}
+                        {% if video.video_details_modified %}<span class="badge bg-danger">UPDATED - {% if video.was_deleted_on_yt %}WENT PRIVATE/DELETED{% else %}NEWLY ADDED{% endif %}</span>{% endif %}<br>
+                        <br>
                     </div>
             </div>
             <div class="ms-5">
@@ -214,38 +238,8 @@
             </li>
 
         {% endfor %}
-       <!-- <form action="{% url 'home' %}" method="post" id="my-form">
-          {% for video in videos %}
-              <li class="d-flex align-items-center">
-                  <input class="form-check-input mx-3 big-checkbox" type="checkbox" value="{{ video.video_id }}" name="video ids" id="checkbox"> <br>
-
-          {% if video.is_unavailable_on_yt %}
-            <a style="background-color: #E73927;" href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" class="list-group-item list-group-item-dark">
-                    {{ video.video_position }}. {{ video.name }}
-                <span class="badge bg-secondary">{{ video.duration }}</span>
-            </a>
-          {% else %}
-                        {{ video.video_position }}. {{ video.name }}
-                        <span class="badge bg-secondary">{{ video.duration }}</span>
-                        {% if video.has_cc %}<span class="badge bg-secondary">CC</span>{% endif %}
-                        {% if video.published_at %}<span class="badge bg-secondary">{{ video.published_at }}</span>{% endif %}
-                      {% if video.view_count %}<span class="badge bg-info">{{ video.view_count|intword|intcomma }} views</span>{% endif %}
 
-                        {% if video.is_duplicate %}<span class="badge bg-primary">duplicate</span>{% endif %}<br>
-                        <button class="btn btn-sm btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasWithBackdrop" aria-controls="offcanvasBottom" hx-get="{% url 'video_details' playlist.playlist_id video.video_id %}" hx-trigger="click" hx-target="#video-details">Details</button>
-
-          {% endif %}
-          {% endfor %}
-        </li>
-        </form>
-
-<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasWithBackdrop" aria-labelledby="offcanvasWithBackdropLabel">
-    <div id="video-details">
-
-      </div>
-
-</div>
+    {% endif %}
         </div>
-    </div> -->
-
+    </div>
 {% endblock %}

+ 1 - 0
apps/main/urls.py

@@ -22,6 +22,7 @@ urlpatterns = [
          name='order_playlist_by'),
     path("playlist/<slug:playlist_id>/mark-as/<slug:mark_as>", views.mark_playlist_as,
          name='mark_playlist_as'),
+    path("playlist/<slug:playlist_id>/update", views.update_playlist, name="update_playlist"),
 
     ### STUFF RELATED TO PLAYLISTS IN BULK
     path("search/playlists/<slug:playlist_type>", views.search_playlists, name="search_playlists"),

+ 86 - 2
apps/main/views.py

@@ -1,3 +1,6 @@
+import datetime
+
+import pytz
 from django.db.models import Q
 from django.http import HttpResponse
 from django.shortcuts import render, redirect
@@ -103,7 +106,25 @@ def view_playlist(request, playlist_id):
         print("Checking if playlist changed...")
         result = Playlist.objects.checkIfPlaylistChangedOnYT(request.user, playlist_id)
 
-        if result[0] == -1:  # playlist changed
+        if result[0] == 1:  # full scan was done (full scan is done for a playlist if a week has passed)
+            deleted_videos, unavailable_videos, added_videos = result[1:]
+
+            print("CHANGES", deleted_videos, unavailable_videos, added_videos)
+
+            playlist_changed_text = ["The following modifications happened to this playlist on YouTube:"]
+            if deleted_videos != 0 or unavailable_videos != 0 or added_videos != 0:
+                if added_videos > 0:
+                    playlist_changed_text.append(f"{added_videos} new video(s) were added")
+                if deleted_videos > 0:
+                    playlist_changed_text.append(f"{deleted_videos} video(s) were deleted")
+                if unavailable_videos > 0:
+                    playlist_changed_text.append(f"{unavailable_videos} video(s) went private/unavailable")
+
+                playlist.playlist_changed_text = "\n".join(playlist_changed_text)
+                playlist.has_playlist_changed = True
+                playlist.save()
+
+        elif result[0] == -1:  # playlist changed
             print("!!!Playlist changed")
 
             current_playlist_vid_count = playlist.video_count
@@ -163,6 +184,8 @@ def all_playlists(request, playlist_type):
 def order_playlist_by(request, playlist_id, order_by):
     playlist = request.user.profile.playlists.get(playlist_id=playlist_id)
 
+    display_text = ""  # what to display when requested order/filter has no videws
+
     if order_by == "popularity":
         videos = playlist.videos.order_by("-like_count")
     elif order_by == "date-published":
@@ -171,12 +194,32 @@ def order_playlist_by(request, playlist_id, order_by):
         videos = playlist.videos.order_by("-view_count")
     elif order_by == "has-cc":
         videos = playlist.videos.filter(has_cc=True)
+        display_text = "No videos in this playlist have CC"
     elif order_by == "duration":
         videos = playlist.videos.order_by("-duration_in_seconds")
+    elif order_by == 'new-updates':
+        videos = []
+        display_text = "No new updates! Note that deleted videos will not show up here."
+        if playlist.has_new_updates:
+            recently_updated_videos = playlist.videos.filter(video_details_modified=True)
+
+            for video in recently_updated_videos:
+                if video.video_details_modified_at + datetime.timedelta(hours=12) < datetime.datetime.now(
+                        pytz.utc):  # expired
+                    video.video_details_modified = False
+                    video.save()
+
+            if playlist.videos.filter(video_details_modified=True).count() == 0:
+                playlist.has_new_updates = False
+                playlist.save()
+            else:
+                videos = playlist.videos.filter(video_details_modified=True)
     else:
         return redirect('home')
 
-    return HttpResponse(loader.get_template("intercooler/videos.html").render({"playlist": playlist, "videos": videos}))
+    return HttpResponse(loader.get_template("intercooler/videos.html").render({"playlist": playlist,
+                                                                               "videos": videos,
+                                                                               "display_text": display_text}))
 
 
 @login_required
@@ -407,3 +450,44 @@ def manage_import_playlists(request):
          "num_playlists_initialized_in_db": num_playlists_initialized_in_db,
          "num_playlists_not_found": num_playlists_not_found
          }))
+
+
+@login_required
+def update_playlist(request, playlist_id):
+    deleted_video_ids, unavailable_videos, added_videos = Playlist.objects.updatePlaylist(request.user, playlist_id)
+
+    playlist = request.user.profile.playlists.get(playlist_id=playlist_id)
+    playlist_changed_text = ["Updates:"]
+
+    if len(added_videos) != 0:
+        playlist_changed_text.append(f"{len(added_videos)} added")
+        for video in added_videos:
+            playlist_changed_text.append(f"--> {video.name}")
+    if len(unavailable_videos) != 0:
+        if len(playlist_changed_text) == 1:
+            playlist_changed_text.append(f"{len(unavailable_videos)} went unavailable")
+        else:
+            playlist_changed_text.append(f"\n{len(unavailable_videos)} went unavailable")
+        for video in unavailable_videos:
+            playlist_changed_text.append(f"--> {video.name}")
+    if len(deleted_video_ids) != 0:
+        if len(playlist_changed_text) == 1:
+            playlist_changed_text.append(f"{len(deleted_video_ids)} deleted")
+        else:
+            playlist_changed_text.append(f"\n{len(deleted_video_ids)} deleted")
+
+        for video_id in deleted_video_ids:
+            video = playlist.videos.get(video_id=video_id)
+            playlist_changed_text.append(f"--> {video.name}")
+            video.delete()
+
+    if len(playlist_changed_text) == 1:
+        playlist_changed_text = ["Successfully refreshed playlist! No new changes found!"]
+    else:
+        playlist_changed_text.append("\nTip: Sort By Updates to see what changed in this playlist.")
+
+    return HttpResponse(loader.get_template("intercooler/updated_playlist.html")
+        .render(
+        {"playlist_changed_text": "\n".join(playlist_changed_text),
+         "playlist": playlist,
+         "videos": playlist.videos.all()}))

+ 0 - 32
apps/users/migrations/0001_initial.py

@@ -1,32 +0,0 @@
-# Generated by Django 3.2.3 on 2021-06-06 06:04
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='Profile',
-            fields=[
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created_at', models.DateTimeField(auto_now_add=True)),
-                ('updated_at', models.DateTimeField(auto_now=True)),
-                ('just_joined', models.BooleanField(default=True)),
-                ('yt_channel_id', models.CharField(default='', max_length=420)),
-                ('import_in_progress', models.BooleanField(default=True)),
-                ('access_token', models.TextField(default='')),
-                ('refresh_token', models.TextField(default='')),
-                ('expires_at', models.DateTimeField(blank=True, null=True)),
-                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
-            ],
-        ),
-    ]

+ 0 - 0
apps/users/migrations/__init__.py


+ 0 - 1
apps/users/urls.py

@@ -11,5 +11,4 @@ urlpatterns = [
 
     path("import/start", views.start_import, name='start'),
     path("import/continue", views.continue_import, name='continue'),
-
 ]

+ 2 - 70
templates/base.html

@@ -98,76 +98,6 @@
 
         <div class="container-fluid">
         <div class="row">
-            <!--
-            <nav class="col-md-3 ms-sm-auto col-lg-2 px-md-4">
-                <div class="position-sticky pt-3">
-                    <ul class="nav flex-column">
-                        <li class="nav-item">
-                            <a class="nav-link" href="{% url 'all_playlists' 'home' %}">
-                              Library
-                            </a>
-                        </li>
-                        <li class="nav-item">
-                            <a class="nav-link active" aria-current="page" href="#">
-                                Add a playlist
-                            </a>
-                        </li>
-                        <li class="nav-item">
-                            <a class="nav-link" href="#">
-                              Create a playlist
-                            </a>
-                        </li>
-                    </ul>
-
-
-                    <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1">
-                        <span>Top 3 Playlists</span>
-                        <a class="link-secondary" href="#collapseExample" aria-label="Add a new report" data-bs-toggle="collapse" role="button" aria-expanded="false" aria-controls="collapseExample">
-                        </a>
-                    </h6>
-                    <ul class="nav flex-column mb-2">
-                        <li class="nav-item">
-                        {% for pl in user_playlists|slice:"0:3" %}
-                            <a class="nav-link" href="{% url 'playlist' pl.playlist_id %}">
-                            {{ pl.name }}
-                            </a>
-                            {% if forloop.last %}
-                            {% endif %}
-                        {% empty %}
-                            <span class="nav-link link-secondary">
-                                None
-                            </span>
-                        {% endfor %}
-                        </li>
-                    </ul>
-
-                                    <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1">
-                        <span>Watching</span>
-
-                    </h6>
-
-                    <ul class="nav flex-column mb-2">
-                            <li class="nav-item">
-                            {% for pl in playlist_watching|slice:"0:3" %}
-                                <a class="nav-link" href="{% url 'playlist' pl.playlist_id %}">
-                                    <span data-feather="file-text"></span>
-                                    {{ pl.name }}
-                                </a>
-                            {% empty %}
-                                <span class="nav-link link-secondary">
-                                    None. Add some!
-                                </span>
-                            {% endfor %}
-                            </li>
-                    </ul>
-
-                    <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1">
-                      <span>Logged in as <b>{{ user.username }}</b></span>
-                    </h6>
-                </div>
-            </nav>
-            -->
-            <!-- <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">-->
             <main class="ms-lg-auto px-lg-5">
     {% block content %}
     {% endblock %}
@@ -315,6 +245,8 @@
         <br>
 
         <script>
+
+
             <!-- for htmx to send csrf_token with every post request -->
             document.body.addEventListener('htmx:configRequest', (event) => {
             event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';