Explorar o código

Initial working site

sleepytaco %!s(int64=3) %!d(string=hai) anos
achega
1f099e50f3

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+/venv/
+/UnTube/client_secrets.json
+/UnTube/secrets.py
+/db.sqlite3
+/modules/t.json
+/modules/you.py
+/modules/youtube-api.py
+/modules/youtube-api-oauth.py
+/.idea/
+/apps/main/migrations/
+/apps/users/migrations/

+ 0 - 0
UnTube/__init__.py


+ 16 - 0
UnTube/asgi.py

@@ -0,0 +1,16 @@
+"""
+ASGI config for UnTube project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'UnTube.settings')
+
+application = get_asgi_application()

+ 6 - 0
UnTube/config.py

@@ -0,0 +1,6 @@
+SOCIAL_AUTH_GOOGLE_CLIENT_ID = '901333803283-1lscbdmukcjj3qp0t3relmla63h6l9k6.apps.googleusercontent.com'
+SOCIAL_AUTH_GOOGLE_CLIENT_SECRET = 'ekdBniL-_mAnNPwCmugfIL2q'
+
+SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/home/'
+SOCIAL_AUTH_LOGIN_URL = '/'
+

+ 159 - 0
UnTube/settings.py

@@ -0,0 +1,159 @@
+"""
+Django settings for UnTube project.
+
+Generated by 'django-admin startproject' using Django 3.2.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.2/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/3.2/ref/settings/
+"""
+
+from pathlib import Path
+from UnTube.secrets import SECRETS
+
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = SECRETS['SECRET_KEY']
+YOUTUBE_V3_API_KEY = SECRETS['YOUTUBE_V3_API_KEY']
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+# Application definition
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+
+    'django.contrib.humanize',  # A set of Django template filters useful for adding a “human touch” to data.
+
+    'django.contrib.sites',
+    'allauth',
+    'allauth.account',
+    'allauth.socialaccount',
+    'allauth.socialaccount.providers.google',  # specifies google as OAuth provider
+
+    'crispy_forms',
+    'apps.users',  # has stuff related to user management in it (login, signup, show homepage, pass reset)
+    'apps.main',  # main app, shows user their homepage
+]
+
+CRISPY_TEMPLATE_PACK = 'bootstrap4'
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'UnTube.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [BASE_DIR / 'templates']
+        ,
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+AUTHENTICATION_BACKENDS = [
+    'django.contrib.auth.backends.ModelBackend',
+    'allauth.account.auth_backends.AuthenticationBackend'
+]
+
+SOCIALACCOUNT_PROVIDERS = {
+    'google': {
+        'SCOPE': [
+            'profile',
+            'email',
+            'https://www.googleapis.com/auth/youtube',
+        ],
+        'AUTH_PARAMS': {
+            # To refresh authentication in the background, set AUTH_PARAMS['access_type'] to offline.
+            'access_type': 'offline',
+        }
+    }
+}
+
+SITE_ID = 3
+
+LOGIN_URL = '/'
+
+LOGIN_REDIRECT_URL = '/home/'
+LOGOUT_REDIRECT_URL = '/home/'
+
+
+WSGI_APPLICATION = 'UnTube.wsgi.application'
+
+# Database
+# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': BASE_DIR / 'db.sqlite3',
+    }
+}
+
+# Password validation
+# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+
+# Internationalization
+# https://docs.djangoproject.com/en/3.2/topics/i18n/
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/3.2/howto/static-files/
+STATIC_URL = '/static/'
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

+ 23 - 0
UnTube/urls.py

@@ -0,0 +1,23 @@
+"""UnTube URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/3.2/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.urls import include, path
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path, include
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+    path('', include("apps.users.urls")),
+    path('', include("apps.main.urls")),
+]

+ 16 - 0
UnTube/wsgi.py

@@ -0,0 +1,16 @@
+"""
+WSGI config for UnTube project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'UnTube.settings')
+
+application = get_wsgi_application()

+ 0 - 0
apps/__init__.py


+ 0 - 0
apps/main/__init__.py


+ 3 - 0
apps/main/admin.py

@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.

+ 6 - 0
apps/main/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class MainConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'apps.main'

+ 814 - 0
apps/main/models.py

@@ -0,0 +1,814 @@
+import googleapiclient.errors
+from django.db import models
+from django.db.models import Q
+from google.oauth2.credentials import Credentials
+from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
+from google.auth.transport.requests import Request
+
+from apps.users.models import Profile
+import re
+from datetime import timedelta
+from googleapiclient.discovery import build
+from UnTube import settings
+
+import pytz
+
+
+# Create your models here.
+
+input = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
+def getVideoIdsStrings(video_ids):
+    output = []
+
+    i = 0
+    while i < len(video_ids):
+        output.append(",".join(video_ids[i:i + 10]))
+        i += 10
+
+    return output
+
+
+def calculateDuration(vid_durations):
+    hours_pattern = re.compile(r'(\d+)H')
+    minutes_pattern = re.compile(r'(\d+)M')
+    seconds_pattern = re.compile(r'(\d+)S')
+
+    total_seconds = 0
+    for duration in vid_durations:
+        hours = hours_pattern.search(duration)  # returns matches in the form "24H"
+        mins = minutes_pattern.search(duration)  # "24M"
+        secs = seconds_pattern.search(duration)  # "24S"
+
+        hours = int(hours.group(1)) if hours else 0  # returns 24
+        mins = int(mins.group(1)) if mins else 0
+        secs = int(secs.group(1)) if secs else 0
+
+        video_seconds = timedelta(
+            hours=hours,
+            minutes=mins,
+            seconds=secs
+        ).total_seconds()
+
+        total_seconds += video_seconds
+
+    return total_seconds
+
+
+def getThumbnailURL(thumbnails):
+    priority = ("maxres", "standard", "high", "medium", "default")
+
+    for quality in priority:
+        if quality in thumbnails:
+            return thumbnails[quality]["url"]
+
+    return ''
+
+
+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):
+        credentials = Credentials(
+            user.profile.access_token,
+            refresh_token=user.profile.refresh_token,
+            # id_token=session.token.get("id_token"),
+            token_uri="https://oauth2.googleapis.com/token",
+            client_id="901333803283-1lscbdmukcjj3qp0t3relmla63h6l9k6.apps.googleusercontent.com",
+            client_secret="ekdBniL-_mAnNPwCmugfIL2q",
+            scopes=['https://www.googleapis.com/auth/youtube']
+        )
+
+        credentials.expiry = user.profile.expires_at.replace(tzinfo=None)
+
+        if not credentials.valid:
+            # if credentials and credentials.expired and credentials.refresh_token:
+            credentials.refresh(Request())
+            user.profile.expires_at = credentials.expiry
+            user.profile.access_token = credentials.token
+            user.profile.refresh_token = credentials.refresh_token
+            user.save()
+
+        with build('youtube', 'v3', credentials=credentials) as youtube:
+            pl_request = youtube.playlists().list(
+                part='contentDetails, snippet, id, status',
+                id=pl_id,  # get playlist details for this playlist id
+                maxResults=50
+            )
+
+            # execute the above request, and store the response
+            try:
+                pl_response = pl_request.execute()
+            except googleapiclient.errors.HttpError:
+                print("YouTube channel not found if mine=True")
+                print("YouTube playlist not found if id=playlist_id")
+                return -1
+
+            playlist_items = []
+
+            for item in pl_response["items"]:
+                playlist_items.append(item)
+
+            while True:
+                try:
+                    pl_request = youtube.playlists().list_next(pl_request, pl_response)
+                    pl_response = pl_request.execute()
+                    for item in pl_response["items"]:
+                        playlist_items.append(item)
+                except AttributeError:
+                    break
+
+        for item in playlist_items:
+            playlist_id = item["id"]
+
+            # check if this playlist already exists in database
+            if user.profile.playlists.filter(playlist_id=playlist_id).count() != 0:
+                playlist = user.profile.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
+
+                # check if playlist count changed on youtube
+                if playlist.video_count != item['contentDetails']['itemCount']:
+                    playlist.has_playlist_changed = True
+                    playlist.save()
+
+    # Used to check if the user has a vaild YouTube channel
+    # Will return -1 if user does not have a YouTube channel
+    def getUserYTChannelID(self, user):
+        credentials = Credentials(
+            user.profile.access_token,
+            refresh_token=user.profile.refresh_token,
+            # id_token=session.token.get("id_token"),
+            token_uri="https://oauth2.googleapis.com/token",
+            client_id="901333803283-1lscbdmukcjj3qp0t3relmla63h6l9k6.apps.googleusercontent.com",
+            client_secret="ekdBniL-_mAnNPwCmugfIL2q",
+            scopes=['https://www.googleapis.com/auth/youtube']
+        )
+
+        credentials.expiry = user.profile.expires_at.replace(tzinfo=None)
+
+        if not credentials.valid:
+            # if credentials and credentials.expired and credentials.refresh_token:
+            credentials.refresh(Request())
+
+        with build('youtube', 'v3', credentials=credentials) as youtube:
+            pl_request = youtube.channels().list(
+                part='id',
+                mine=True  # get playlist details for this user's playlists
+            )
+
+            pl_response = pl_request.execute()
+
+            if pl_response['pageInfo']['totalResults'] == 0:
+                print("Looks like do not have a channel on youtube. Create one to import all of your playlists. Retry?")
+                return -1
+            else:
+                user.profile.yt_channel_id = pl_response['items'][0]['id']
+                user.save()
+
+        return 0
+
+    # Set pl_id as None to retrive all the playlists from authenticated user. Playlists already imported will be skipped by default.
+    # Set pl_id = <valid playlist id>, to import that specific playlist into the user's account
+    def initPlaylist(self, user, pl_id):  # takes in playlist id and saves all of the vids in user's db
+        current_user = user.profile
+
+        credentials = Credentials(
+            user.profile.access_token,
+            refresh_token=user.profile.refresh_token,
+            # id_token=session.token.get("id_token"),
+            token_uri="https://oauth2.googleapis.com/token",
+            client_id="901333803283-1lscbdmukcjj3qp0t3relmla63h6l9k6.apps.googleusercontent.com",
+            client_secret="ekdBniL-_mAnNPwCmugfIL2q",
+            scopes=['https://www.googleapis.com/auth/youtube']
+        )
+
+        credentials.expiry = user.profile.expires_at.replace(tzinfo=None)
+
+        if not credentials.valid:
+            # if credentials and credentials.expired and credentials.refresh_token:
+            credentials.refresh(Request())
+            user.profile.expires_at = credentials.expiry
+            user.profile.access_token = credentials.token
+            user.profile.refresh_token = credentials.refresh_token
+            user.save()
+
+        with build('youtube', 'v3', credentials=credentials) as youtube:
+            if pl_id is not None:
+                pl_request = youtube.playlists().list(
+                    part='contentDetails, snippet, id, player, status',
+                    id=pl_id,  # get playlist details for this playlist id
+                    maxResults=50
+                )
+            else:
+                pl_request = youtube.playlists().list(
+                    part='contentDetails, snippet, id, player, status',
+                    mine=True,  # get playlist details for this playlist id
+                    maxResults=50
+                )
+            # execute the above request, and store the response
+            try:
+                pl_response = pl_request.execute()
+            except googleapiclient.errors.HttpError:
+                print("YouTube channel not found if mine=True")
+                print("YouTube playlist not found if id=playlist_id")
+                return -1
+
+            if pl_response["pageInfo"]["totalResults"] == 0:
+                print("No playlists created yet on youtube.")
+                return -2
+
+            playlist_items = []
+
+            for item in pl_response["items"]:
+                playlist_items.append(item)
+
+            while True:
+                try:
+                    pl_request = youtube.playlists().list_next(pl_request, pl_response)
+                    pl_response = pl_request.execute()
+                    for item in pl_response["items"]:
+                        playlist_items.append(item)
+                except AttributeError:
+                    break
+
+        for item in playlist_items:
+            playlist_id = item["id"]
+
+            # check if this playlist already exists in database
+            if current_user.playlists.filter(playlist_id=playlist_id).count() != 0:
+                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
+
+                # check if playlist count changed on youtube
+                if playlist.video_count != item['contentDetails']['itemCount']:
+                    playlist.has_playlist_changed = True
+                    playlist.save()
+            else:  # no such playlist in database
+                ### MAKE THE PLAYLIST AND LINK IT TO CURRENT_USER
+                playlist = Playlist(  # create the playlist and link it to current user
+                    playlist_id=playlist_id,
+                    name=item['snippet']['title'],
+                    description=item['snippet']['description'],
+                    published_at=item['snippet']['publishedAt'],
+                    thumbnail_url=getThumbnailURL(item['snippet']['thumbnails']),
+                    channel_id=item['snippet']['channelId'] if 'channelId' in
+                                                               item['snippet'] else '',
+                    channel_name=item['snippet']['channelTitle'] if 'channelTitle' in
+                                                                    item[
+                                                                        'snippet'] else '',
+                    video_count=item['contentDetails']['itemCount'],
+                    is_private_on_yt=True if item['status']['privacyStatus'] == 'private' else False,
+                    playlist_yt_player_HTML=item['player']['embedHtml'],
+                    user=current_user
+                )
+                playlist.save()
+
+                playlist = current_user.playlists.get(playlist_id__exact=playlist_id)
+
+                ### 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
+                            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(
+                                    video_id=video_id,
+                                    name=item['snippet']['title'],
+                                    is_unavailable_on_yt=True,
+                                    playlist=playlist,
+                                    video_position=item['snippet']['position'] + 1
+                                )
+                                video.save()
+                            else:
+                                video = Video(
+                                    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.save()
+                            video_ids.append(video_id)
+                        else:  # video found in db
+                            video = playlist.videos.get(video_id=video_id)
+
+                            # check if the video became unavailable on youtube
+                            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.is_duplicate = True
+                            playlist.has_duplicate_videos = True
+                            video.save()
+
+                    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
+                                    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(
+                                            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
+                                        )
+                                        video.save()
+                                    else:
+                                        video = Video(
+                                            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.save()
+                                    video_ids.append(video_id)
+                                else:  # video found in db
+                                    video = playlist.videos.get(video_id=video_id)
+
+                                    # check if the video became unavailable on youtube
+                                    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.is_duplicate = True
+                                    playlist.has_duplicate_videos = True
+                                    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):  # that means some videos in the playlist are deleted
+                    playlist.has_unavailable_videos = True
+
+                playlist.is_in_db = True
+
+                playlist.save()
+
+        if pl_id is None:
+            user.profile.just_joined = False
+            user.profile.import_in_progress = False
+            user.save()
+
+    def getAllPlaylistsFromYT(self, user):
+        '''
+        Retrieves all of user's playlists from YT and stores them in the Playlist model. Note: only stores
+        the few of the columns of each playlist in every row, and has is_in_db column as false as no videos will be
+        saved.
+        :param user:
+        :return:
+        '''
+        result = {"status": 0, "num_of_playlists": 0, "first_playlist_name": "N/A"}
+
+        current_user = user.profile
+
+        credentials = Credentials(
+            user.profile.access_token,
+            refresh_token=user.profile.refresh_token,
+            # id_token=session.token.get("id_token"),
+            token_uri="https://oauth2.googleapis.com/token",
+            client_id="901333803283-1lscbdmukcjj3qp0t3relmla63h6l9k6.apps.googleusercontent.com",
+            client_secret="ekdBniL-_mAnNPwCmugfIL2q",
+            scopes=['https://www.googleapis.com/auth/youtube']
+        )
+
+        credentials.expiry = user.profile.expires_at.replace(tzinfo=None)
+
+        if not credentials.valid:
+            # if credentials and credentials.expired and credentials.refresh_token:
+            credentials.refresh(Request())
+            user.profile.expires_at = credentials.expiry
+            user.profile.access_token = credentials.token
+            user.profile.refresh_token = credentials.refresh_token
+            user.save()
+
+        with build('youtube', 'v3', credentials=credentials) as youtube:
+            pl_request = youtube.playlists().list(
+                part='contentDetails, snippet, id, player, status',
+                mine=True,  # get playlist details for this playlist id
+                maxResults=50
+            )
+            # execute the above request, and store the response
+            try:
+                pl_response = pl_request.execute()
+            except googleapiclient.errors.HttpError:
+                print("YouTube channel not found if mine=True")
+                print("YouTube playlist not found if id=playlist_id")
+                result["status"] = -1
+                return result
+
+            if pl_response["pageInfo"]["totalResults"] == 0:
+                print("No playlists created yet on youtube.")
+                result["status"] = -2
+                return result
+
+            playlist_items = []
+
+            for item in pl_response["items"]:
+                playlist_items.append(item)
+
+            while True:
+                try:
+                    pl_request = youtube.playlists().list_next(pl_request, pl_response)
+                    pl_response = pl_request.execute()
+                    for item in pl_response["items"]:
+                        playlist_items.append(item)
+                except AttributeError:
+                    break
+
+        result["num_of_playlists"] = len(playlist_items)
+        result["first_playlist_name"] = playlist_items[0]["snippet"]["title"]
+
+        for item in playlist_items:
+            playlist_id = item["id"]
+
+            # check if this playlist already exists in database
+            if current_user.playlists.filter(playlist_id=playlist_id).count() != 0:
+                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
+
+                # check if playlist count changed on youtube
+                if playlist.video_count != item['contentDetails']['itemCount']:
+                    playlist.has_playlist_changed = True
+                    playlist.save()
+            else:  # no such playlist in database
+                ### MAKE THE PLAYLIST AND LINK IT TO CURRENT_USER
+                playlist = Playlist(  # create the playlist and link it to current user
+                    playlist_id=playlist_id,
+                    name=item['snippet']['title'],
+                    description=item['snippet']['description'],
+                    published_at=item['snippet']['publishedAt'],
+                    thumbnail_url=getThumbnailURL(item['snippet']['thumbnails']),
+                    channel_id=item['snippet']['channelId'] if 'channelId' in
+                                                               item['snippet'] else '',
+                    channel_name=item['snippet']['channelTitle'] if 'channelTitle' in
+                                                                    item[
+                                                                        'snippet'] else '',
+                    video_count=item['contentDetails']['itemCount'],
+                    is_private_on_yt=True if item['status']['privacyStatus'] == 'private' else False,
+                    playlist_yt_player_HTML=item['player']['embedHtml'],
+                    user=current_user
+                )
+                playlist.save()
+
+        return result
+
+    def getAllVideosForPlaylist(self, user, playlist_id):
+
+        current_user = user.profile
+
+        credentials = Credentials(
+            user.profile.access_token,
+            refresh_token=user.profile.refresh_token,
+            # id_token=session.token.get("id_token"),
+            token_uri="https://oauth2.googleapis.com/token",
+            client_id="901333803283-1lscbdmukcjj3qp0t3relmla63h6l9k6.apps.googleusercontent.com",
+            client_secret="ekdBniL-_mAnNPwCmugfIL2q",
+            scopes=['https://www.googleapis.com/auth/youtube']
+        )
+
+        credentials.expiry = user.profile.expires_at.replace(tzinfo=None)
+
+        if not credentials.valid:
+            # if credentials and credentials.expired and credentials.refresh_token:
+            credentials.refresh(Request())
+            user.profile.expires_at = credentials.expiry
+            user.profile.access_token = credentials.token
+            user.profile.refresh_token = credentials.refresh_token
+            user.save()
+
+        playlist = current_user.playlists.get(playlist_id__exact=playlist_id)
+
+        ### 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
+                    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(
+                            video_id=video_id,
+                            name=item['snippet']['title'],
+                            is_unavailable_on_yt=True,
+                            playlist=playlist,
+                            video_position=item['snippet']['position'] + 1
+                        )
+                        video.save()
+                    else:
+                        video = Video(
+                            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.save()
+                    video_ids.append(video_id)
+                else:  # video found in db
+                    video = playlist.videos.get(video_id=video_id)
+
+                    # check if the video became unavailable on youtube
+                    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.is_duplicate = True
+                    playlist.has_duplicate_videos = True
+                    video.save()
+
+            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
+                            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(
+                                    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
+                                )
+                                video.save()
+                            else:
+                                video = Video(
+                                    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.save()
+                            video_ids.append(video_id)
+                        else:  # video found in db
+                            video = playlist.videos.get(video_id=video_id)
+
+                            # check if the video became unavailable on youtube
+                            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.is_duplicate = True
+                            playlist.has_duplicate_videos = True
+                            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):  # that means some videos in the playlist are deleted
+            playlist.has_unavailable_videos = True
+
+        playlist.is_in_db = True
+
+        playlist.save()
+
+
+class Playlist(models.Model):
+    # playlist details
+    playlist_id = models.CharField(max_length=150)
+    name = models.CharField(max_length=150, blank=True)
+    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)
+
+    # 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)
+
+    user = models.ForeignKey(Profile, on_delete=models.CASCADE,
+                             related_name="playlists")  # a user can have many playlists
+    playlist_duration = models.CharField(max_length=69, blank=True)  # string version of playlist dureation
+    playlist_duration_in_seconds = models.IntegerField(default=0)
+    has_unavailable_videos = models.BooleanField(default=False)  # if videos in playlist are private/deleted
+
+    # playlist is made by this channel
+    channel_id = models.CharField(max_length=420, blank=True)
+    channel_name = models.CharField(max_length=420, blank=True)
+
+    user_notes = models.CharField(max_length=420, default="")  # user can take notes on the playlist and save them
+    user_label = models.CharField(max_length=100, default="")  # custom user given name for this playlist
+
+    # manage playlist
+    marked_as = models.CharField(default="",
+                                 max_length=100)  # can be set to "none", "watching", "on hold", "plan to watch"
+    is_favorite = models.BooleanField(default=False, blank=True)  # to mark playlist as fav
+    num_of_accesses = models.IntegerField(default="0")  # tracks num of times this playlist was opened by user
+    has_playlist_changed = models.BooleanField(default=False)  # determines whether playlist was modified online or not
+    is_private_on_yt = models.BooleanField(default=False)
+    is_from_yt = models.BooleanField(default=True)
+    has_duplicate_videos = models.BooleanField(default=False)  # duplicate videos will not be shown on site
+
+    # for UI
+    view_in_grid_mode = models.BooleanField(default=False)  # if False, videso will be showed in a list
+
+    # set playlist manager
+    objects = PlaylistManager()
+
+    # for import
+    is_in_db = models.BooleanField(default=False)  # is true when all the videos of a playlist have been imported
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    def __str__(self):
+        return "Playlist Len " + str(self.video_count)
+
+
+class Video(models.Model):
+    # video details
+    video_id = models.CharField(max_length=100)
+    name = models.CharField(max_length=100, blank=True)
+    duration = models.CharField(max_length=100, blank=True)
+    duration_in_seconds = models.IntegerField(default=0)
+    thumbnail_url = models.CharField(max_length=420, blank=True)
+    published_at = models.DateTimeField(blank=True, null=True)
+    description = models.CharField(max_length=420, default="")
+    has_cc = models.BooleanField(default=False, blank=True, null=True)
+
+    user_notes = models.CharField(max_length=420, default="")  # user can take notes on the video and save them
+
+    # video stats
+    view_count = models.IntegerField(default=0)
+    like_count = models.IntegerField(default=0)
+    dislike_count = models.IntegerField(default=0)
+
+    yt_player_HTML = models.CharField(max_length=420, blank=True)
+
+    # video is made by this channel
+    channel_id = models.CharField(max_length=420, blank=True)
+    channel_name = models.CharField(max_length=420, blank=True)
+
+    # which playlist this video belongs to, and position of that video in the playlist (i.e ALL videos belong to some pl)
+    playlist = models.ForeignKey(Playlist, related_name="videos", on_delete=models.CASCADE)
+    video_position = models.CharField(max_length=69, blank=True)
+
+    # manage video
+    is_duplicate = models.BooleanField(default=False)  # True if the same video exists more than once in the playlist
+    is_unavailable_on_yt = models.BooleanField(
+        default=False)  # True if the video was unavailable (private/deleted) when the API call was first made
+    was_deleted_on_yt = models.BooleanField(default=False)  # True if video became unavailable on a subsequent API call
+    is_marked_as_watched = models.BooleanField(default=False, blank=True)  # mark video as watched
+    is_favorite = models.BooleanField(default=False, blank=True)  # mark video as favorite
+    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

+ 124 - 0
apps/main/templates/all_playlists.html

@@ -0,0 +1,124 @@
+
+{% extends 'base.html' %}
+{% block content %}
+    <div id="search-results">
+
+            {% if messages %}
+            {% for message in messages %}
+              <div class="alert {% if message.tags %} alert-{{ message.tags }}{% endif %} alert-dismissible fade show" role="alert">
+                  {{ message|safe }}
+                  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+              </div>
+            {% endfor %}
+    {% endif %}
+        <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+            <h1 class="h2">{{ playlist_type_display }} <span class="badge bg-primary rounded-pill">{{ playlists.count }}</span></h1>
+
+            <div class="btn-toolbar mb-2 mb-md-0">
+                <div class="btn-group me-2">
+                    <button type="button" class="btn btn-outline-info">Grid</button>
+                    <button type="button" class="btn btn-outline-info">List</button>
+                </div>
+                <div class="btn-group">
+                  <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><a class="dropdown-item" href="{% url 'order_playlists_by' playlist_type 'playlist-duration-in-seconds' %}">Duration</a></li>
+                    <li><a class="dropdown-item" href="{% url 'order_playlists_by' playlist_type 'video-count' %}"># Videos</a></li>
+                    <li><a class="dropdown-item" href="#">Recently Accessed</a></li>
+                  </ul>
+                </div>
+
+            </div>
+
+        </div>
+
+        {% if playlists %}
+
+        <div class="">
+            <input class="form-control" type="text"
+       name="search" placeholder="Begin to search playlists..."
+       hx-post="{% url 'search_playlists' playlist_type %}"
+       hx-trigger="keyup changed delay:500ms"
+       hx-target="#search-results"
+       hx-indicator=".htmx-indicator">
+          <br>
+        </div>
+
+        <div id="search-results">
+            <div class="row row-cols-1 row-cols-md-3 g-4">
+                {% for playlist in playlists %}
+                <div class="col">
+                    <div class="card h-100" style="background-color: #1A4464;">
+                        <a style="background-color: #9CC5E5;" href="{% url 'playlist' playlist.playlist_id %}" class="list-group-item list-group-item-action" aria-current="true">
+
+                            <div class="card-body">
+
+                                <h5 class="card-title">
+                                    {{ playlist.name }}
+
+                                    {% if playlist.is_private_on_yt %}<small><span class="badge bg-light text-dark">Private</span></small> {% endif %}
+                                    {% if playlist.is_from_yt %}<small><span class="badge bg-danger text-dark">YT</span></small> {% endif %}
+
+                                </h5>
+                                <p class="card-text">
+                                    {% if playlist.description %}
+                                        {{ playlist.description }}
+                                    {% else %}
+                                        No description
+                                    {% endif %}
+                                </p>
+                                <small>
+                                    <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} videos</span>
+                                    <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                                </small>
+                            </div>
+                        </a>
+                    </div>
+                </div>
+                {% endfor %}
+
+                {% else %}
+                <h5 class="text-white align-content-center">Nothing to see here. Add something!</h5>
+            </div>
+        </div>
+                    {% endif %}
+
+    <br>
+
+        <!-- list view
+          <div class="table-responsive">
+              {% if playlists %}
+                <div class="list-group">
+                    {% for playlist in playlists %}
+
+                    <a style="background-color: #969291;" href="https://www.youtube.com/playlist?list={{ playlist.playlist_id }}" class="list-group-item list-group-item-action" aria-current="true">
+                    <div class="d-flex w-100 justify-content-between">
+                      <h5 class="mb-1">{{ playlist.name }}</h5>
+                      <small>3 days ago</small>
+                    </div>
+                    <p class="mb-1">
+                        {% if playlist.description %}
+                            {{ playlist.description }}
+                        {% else %}
+                            No description
+                        {% endif %}
+                    </p>
+                    <small>
+                        <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} videos</span>
+                        <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                    </small>
+                    </a>
+
+                    {% endfor %}
+                </div>
+
+                    {% else %}
+                  <h2 class="text-white align-content-center">Nothing to see here.</h2>
+                {% endif %}
+          </div>
+        -->
+
+    </div>
+{% endblock %}

+ 200 - 0
apps/main/templates/home.html

@@ -0,0 +1,200 @@
+
+{% extends 'base.html' %}
+{% block content %}
+
+                {% for message in messages %}
+                  <div class="alert alert-success alert-dismissible fade show" role="alert">
+                      {{ message }}
+                      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                  </div>
+                {% endfor %}
+
+    {% if user.profile.just_joined %}
+            {% if not channel_found %}
+                <br>
+
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h2>Uh-oh, we were not able to find any YouTube channel linked to this Google account. Please create a YouTube channel to be able to import/export playlists back and forth between YouTube and UnTube.</h2>
+                </div>
+
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <a href="{% url 'home' %}" class="btn btn-lg btn-danger">Retry</a>
+                </div>
+            {% endif %}
+
+    {% else %}
+
+        {% if import_successful %}
+                            <br>
+                <br>
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h1>Welcome to UnTube, {{ user.username|capfirst }}</h1>
+                </div>
+                  <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                            <h2>{{ user.profile.playlists.all.count }} playlists from YouTube have been successfully imported.</h2>
+                  </div>
+
+                  <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                        <h3>You'll now be able to import/export playlists from YouTube and UnTube :)</h3>
+                  </div>
+
+
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <a href="{% url 'home' %}" class="btn btn-lg btn-success">Go to Dashboard</a>
+                </div>
+
+            {% else %}
+            <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+
+                <h1 class="h2">Dashboard</h1>
+
+                <!--
+                <div class="btn-toolbar mb-2 mb-md-0">
+              <div class="btn-group me-2">
+                <button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
+                <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
+              </div>
+              <button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
+                <span data-feather="calendar"></span>
+                This week
+              </button>
+
+
+            </div>
+                -->
+                <span><small>Logged in as <b>{{ user.username }}</b></small></span>
+
+            </div>
+                        {% if watching %}
+                <div class="border border-5 rounded-3 border-primary p-3">
+            <h3>Continue Watching</h3>
+           <div class="row row-cols-1 row-cols-md-3 g-4 text-dark mt-0">
+                {% for playlist in watching|slice:"0:2" %}
+                <div class="col">
+                    <div class="card">
+                        <a style="background-color: #7e89c2;" href="{% url 'playlist' playlist.playlist_id %}" class="list-group-item list-group-item-action" aria-current="true">
+                            <div class="card-body">
+                        <h5 class="card-title">
+                           {{ playlist.name }}
+                            {% if playlist.is_private_on_yt %}<small><span class="badge bg-light text-dark">Private</span></small> {% endif %}
+                            {% if playlist.is_from_yt %}<small><span class="badge bg-danger text-dark">YT</span></small> {% endif %}
+                        </h5>
+                        <p class="card-text">
+                            {% if playlist.description %}
+                                {{ playlist.description }}
+                            {% else %}
+                                No description
+                            {% endif %}
+                        </p>
+                        <small>
+                            <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} videos</span>
+                            <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                            <span class="badge bg-secondary rounded-pill">{{ playlist.num_of_accesses }} clicks </span>
+                            <span class="badge bg-info rounded-pill">2/23 viewed</span>
+
+                        </small>
+                        </div>
+                        </a>
+                    </div>
+                </div>
+                    {% if forloop.counter == 2 %}
+                        {% if watching.count|add:"-2" != 0 %}
+                        <div class="col">
+                            <div class="card">
+                                <a style="background-color: #7e89c2;" href="{% url 'all_playlists' 'watching' %}" class="list-group-item list-group-item-action" aria-current="true">
+                                    <div class="card-body">
+
+                                        <p class="card-text">
+                                            <h3>+ {{ watching.count|add:"-2" }} more</h3>
+                                        </p>
+                                     </div>
+                                </a>
+                            </div>
+                        </div>
+                        {% endif %}
+                    {% endif %}
+                {% endfor %}
+            </div>
+            </div>
+                {% endif %}
+
+            <br>
+            <h3>Most viewed playlists <a href="{% url 'all_playlists' 'all' %}" class="btn btn-sm btn-info">View All</a></h3>
+
+            <div class="row row-cols-1 row-cols-md-3 g-4 text-dark mt-0">
+                {% for playlist in user_playlists|slice:"0:3" %}
+                <div class="col">
+                    <div class="card">
+                        <a style="background-color: #35795b;" href="{% url 'playlist' playlist.playlist_id %}" class="list-group-item list-group-item-action" aria-current="true">
+                            <div class="card-body">
+                        <h5 class="card-title">
+                           #{{ forloop.counter }} <br><br>{{ playlist.name }}
+                            {% if playlist.is_private_on_yt %}<small><span class="badge bg-light text-dark">Private</span></small> {% endif %}
+                            {% if playlist.is_from_yt %}<small><span class="badge bg-danger text-dark">YT</span></small> {% endif %}
+                        </h5>
+                        <p class="card-text">
+                            {% if playlist.description %}
+                                {{ playlist.description }}
+                            {% else %}
+                                No description
+                            {% endif %}
+                        </p>
+                        <small>
+                            <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} videos</span>
+                            <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                            <span class="badge bg-secondary rounded-pill">{{ playlist.num_of_accesses }} clicks </span>
+
+                        </small>
+                        </div>
+                        </a>
+                    </div>
+                </div>
+                {% endfor %}
+            </div>
+
+
+        <br>
+        <br>
+            <h3>Recently Added</h3>
+        <br>
+          <h2>Add a playlist</h2>
+        <br>
+          <div class="table-responsive">
+              <form class="form" role="form" method="post" action="{% url 'home' %}">
+                                          {% csrf_token %}
+
+                  <label for="pl">Enter playlist id:<br></label>
+                  <input name="playlist-id" type="text" id="pl">
+                  <button type="submit" class="btn btn-info">Submit</button>
+                    <div class="spinner-border text-light" role="status">
+                      <span class="visually-hidden">Loading...</span>
+                    </div>
+                                    <input class="form-check-input mx-3 big-checkbox" type="checkbox" value="a" name="s" id="checkbox"> <br>
+                                    <input class="form-check-input mx-3 big-checkbox" type="checkbox" value="b" name="s" id="checkbox"> <br>
+                                    <input class="form-check-input mx-3 big-checkbox" type="checkbox" value="c" name="s" id="checkbox"> <br>
+                                    <input class="form-check-input mx-3 big-checkbox" type="checkbox" value="d" name="s" id="checkbox"> <br>
+
+              </form>
+
+          <br>
+              <div class="list-group">
+                      <a href="https://www.youtube.com/playlist?list={{ playlist.playlist_id }}" class="list-group-item list-group-item-action active">
+                        <b>{{ playlist.name }}</b> by {{ playlist.channel_name }}
+                          <span class="badge badge-dark">{{ playlist.video_count }} videos</span>
+                          <span class="badge badge-dark">{{ playlist.playlist_duration }}</span>
+                          <span class="badge badge-dark">{% if playlist.has_unavailable_videos %}Some videos in the playlist are deleted{% endif %}</span>
+                      </a>
+                  {% for video in videos %}
+                    <a href="https://www.youtube.com/watch?v={{ video.video_id }}&list={{ video.playlist.playlist_id }}" class="list-group-item {% if not video.is_up %}list-group-item-dark{% else %}list-group-item-action{% endif %}">{{ video.video_position }}. {{ video.name }}</a>
+                  {% endfor %}
+            </div>
+
+
+          </div>
+
+
+        {% endif %}
+
+    {% endif %}
+
+{% endblock %}

+ 98 - 0
apps/main/templates/import_in_progress.html

@@ -0,0 +1,98 @@
+
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <meta name="description" content="">
+        <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
+        <meta name="generator" content="Hugo 0.83.1">
+        <title>Import in progress</title>
+
+        <style type="text/css">
+            .progress {
+                height: 20px;
+                margin-bottom: 20px;
+                overflow: hidden;
+                background-color: #f5f5f5;
+                border-radius: 4px;
+                box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
+            }
+            .progress-bar {
+                float: left;
+                width: 0%;
+                height: 100%;
+                font-size: 12px;
+                line-height: 20px;
+                color: #fff;
+                text-align: center;
+                background-color: #337ab7;
+                -webkit-box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
+                box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
+                -webkit-transition: width .6s ease;
+                -o-transition: width .6s ease;
+                transition: width .6s ease;
+            }
+
+        </style>
+
+
+          <script src="https://unpkg.com/htmx.org@1.4.1"></script>
+        <script src="https://unpkg.com/htmx.org/dist/ext/class-tools.js"></script>
+
+
+        <link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Open+Sans&display=swap" rel="stylesheet">
+
+        <!-- Bootstrap core CSS -->
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
+    </head>
+    <body class="bg-dark text-white"  style="font-family: 'Fredoka One'">
+
+        <div class="container-fluid">
+
+
+            <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h1>UnTube</h1>
+
+            </div>
+            <br>
+            <br>
+
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h3>{{ user.username|capfirst }}, click the button below to import your YouTube playlists into UnTube.</h3>
+                </div>
+
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h3>It will take a few minutes depending on the number of playlists you have.</h3>
+                </div>
+                <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h3>Please be patient :)</h3>
+                </div>
+
+
+                <div hx-target="this" hx-swap="outerHTML">
+                    <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+
+                      <button class="btn btn-lg btn-success" hx-post="{% url 'start' %}">
+                                Import
+                      </button>
+                    </div>
+                </div>
+
+        </div>
+
+        </div>
+
+    <script>
+            <!-- for htmx to send csrf_token with every post request -->
+    document.body.addEventListener('htmx:configRequest', (event) => {
+        event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
+    })
+
+    </script>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-gtEjrD/SeCtmISkJkNUaaKMoLD0//ElJ19smozuHV6z3Iehds+3Ulb9Bn9Plx0x4" crossorigin="anonymous"></script>
+    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script><script src="dashboard.js"></script>
+    </body>
+</html>

+ 4 - 0
apps/main/templates/intercooler/messages.html

@@ -0,0 +1,4 @@
+<div class="alert alert-{{ message_type }} alert-dismissible fade show" role="alert">
+  {{ message_content }}
+  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+</div>

+ 113 - 0
apps/main/templates/intercooler/playlists.html

@@ -0,0 +1,113 @@
+    {% if messages %}
+            {% for message in messages %}
+              <div class="alert {% if message.tags %} alert-{{ message.tags }}{% endif %} alert-dismissible fade show" role="alert">
+                  {{ message|safe }}
+                  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+              </div>
+            {% endfor %}
+    {% endif %}
+        <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+            <h1 class="h2">{{ playlist_type_display }} <span class="badge bg-primary rounded-pill">{{ playlists.count }}</span></h1>
+
+            <div class="btn-toolbar mb-2 mb-md-0">
+                <div class="btn-group me-2">
+                    <button type="button" class="btn btn-outline-info">Grid</button>
+                    <button type="button" class="btn btn-outline-info">List</button>
+                </div>
+                <div class="btn-group">
+                  <button type="button" class="btn btn-outline-info dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                    Sort By
+                  </button>
+                  <ul class="dropdown-menu">
+                    <li><a class="dropdown-item" href="{% url 'order_playlists_by' 'all' 'playlist_duration_in_seconds' %}">Duration</a></li>
+                    <li><a class="dropdown-item" href="{% url 'order_playlists_by' 'all' 'video_count' %}"># Videos</a></li>
+                    <li><a class="dropdown-item" href="#">Something else here</a></li>
+                  </ul>
+                </div>
+
+            </div>
+
+        </div>
+
+        <div class="">
+            <input class="form-control" type="text"
+       name="search" placeholder="Begin to search playlists..."
+                   value="{{ search_query }}"
+       hx-post="{% url 'search_playlists' playlist_type %}"
+       hx-trigger="keyup changed delay:200ms"
+       hx-target="#search-results"
+       hx-indicator=".htmx-indicator" autofocus onfocus="this.setSelectionRange(this.value.length,this.value.length);">
+            <br>
+        </div>
+
+        <div>
+            <div class="row row-cols-1 row-cols-md-3 g-4">
+                {% if playlists %}
+                {% for playlist in playlists %}
+                <div class="col">
+                    <div class="card h-100" style="background-color: #9CC5E5;">
+                        <a style="background-color: #9CC5E5;" href="{% url 'playlist' playlist.playlist_id %}" class="list-group-item list-group-item-action" aria-current="true">
+
+                            <div class="card-body">
+
+                                <h5 class="card-title">
+                                    {{ playlist.name }}
+                                    {% if playlist.is_private_on_yt %}<small><span class="badge bg-light text-dark">Private</span></small> {% endif %}
+                                    {% if playlist.is_from_yt %}<small><span class="badge bg-danger text-dark">YT</span></small> {% endif %}
+                                </h5>
+                                <p class="card-text">
+                                    {% if playlist.description %}
+                                        {{ playlist.description }}
+                                    {% else %}
+                                        No description
+                                    {% endif %}
+                                </p>
+                                <small>
+                                    <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} videos</span>
+                                    <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                                </small>
+                            </div>
+                        </a>
+                    </div>
+                </div>
+                {% endfor %}
+
+                {% else %}
+                <h5 class="text-white align-content-center">Nothing found :(</h5>
+                {% endif %}
+            </div>
+        </div>
+    <br>
+
+        <!-- list view
+          <div class="table-responsive">
+              {% if playlists %}
+                <div class="list-group">
+                    {% for playlist in playlists %}
+
+                    <a style="background-color: #969291;" href="https://www.youtube.com/playlist?list={{ playlist.playlist_id }}" class="list-group-item list-group-item-action" aria-current="true">
+                    <div class="d-flex w-100 justify-content-between">
+                      <h5 class="mb-1">{{ playlist.name }}</h5>
+                      <small>3 days ago</small>
+                    </div>
+                    <p class="mb-1">
+                        {% if playlist.description %}
+                            {{ playlist.description }}
+                        {% else %}
+                            No description
+                        {% endif %}
+                    </p>
+                    <small>
+                        <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} videos</span>
+                        <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                    </small>
+                    </a>
+
+                    {% endfor %}
+                </div>
+
+                    {% else %}
+                  <h2 class="text-white align-content-center">Nothing to see here.</h2>
+                {% endif %}
+          </div>
+        -->

+ 68 - 0
apps/main/templates/intercooler/progress_bar.html

@@ -0,0 +1,68 @@
+{% if channel_found %}
+    {% if not done %}
+    <div hx-target="this"
+        hx-get="{% url 'continue' %}"
+        hx-trigger="load"
+        hx-swap="outerHTML">
+        <div class="d-flex justify-content-center pt-3 pb-2 mb-3" style="background-color: cadetblue">
+
+            <div class="w-50">
+                <div class="d-flex justify-content-center">
+                    <h3>Importing playlist '{{ playlist_name }}' from YouTube</h3>
+                </div>
+                <div class="d-flex justify-content-center">
+                    <h3>({{ playlists_imported }}/{{ total_playlists }} playlists imported)</h3>
+                </div>
+                <div class="progress">
+                    <div id="pb" class="progress-bar progress-bar-striped progress-bar-animated bg-warning text-dark" style="width:{{ progress }}%" role="progressbar" aria-valuemin="0" aria-valuemax="100">{{ progress }}%</div>
+                </div>
+            </div>
+        </div>
+    </div>
+    {% else %}
+    <div class="d-flex justify-content-center pt-3 pb-2 mb-3" style="background-color: #5fa075">
+
+        <div class="w-75">
+            <div class="d-flex justify-content-center">
+                    <h3>Finished importing all of your playlists from YouTube</h3>
+                </div>
+                <div class="d-flex justify-content-center">
+                    <h3>({{ playlists_imported }}/{{ total_playlists }} playlists imported)</h3>
+                </div>
+            <div class="progress">
+                    <div id="pb" class="progress-bar progress-bar-striped progress-bar-animated bg-success" style="width:{{ progress }}%" role="progressbar" aria-valuemin="0" aria-valuemax="100">{{ progress }}%</div>
+            </div>
+            <div class="d-flex justify-content-center">
+                <a class="btn btn-success" href="{% url 'home' %}">Continue</a>
+            </div>
+        </div>
+    </div>
+    {% endif %}
+{% else %}
+
+    <div hx-target="this" hx-swap="outerHTML">
+        <div class="d-flex justify-content-center pt-3 pb-2 mb-3" style="background-color: #c9942a">
+
+            <div class="w-75">
+                <div class="d-flex justify-content-center">
+                    <h3>Uh-oh, we were not able to find any YouTube channel linked to this Google account. Please create a YouTube channel to be able to import/export playlists back and forth between YouTube and UnTube.</h3>
+                </div>
+                <br>
+                <div class="d-flex justify-content-center">
+
+                    <div class="btn-group-lg">
+                  <button class="btn btn-lg btn-danger" hx-post="{% url 'start' %}">
+                            Retry
+                  </button>
+
+                    <a class="btn btn-lg btn-outline-danger" href="{% url 'log_out' %}">
+                      Log out
+                    </a>
+                        </div>
+                </div>
+            </div>
+        </div>
+
+    </div>
+
+{% endif %}

+ 114 - 0
apps/main/templates/intercooler/search_untube.html

@@ -0,0 +1,114 @@
+{% load humanize %}
+        <div class="bg-dark">
+            <input class="form-control me-lg-2 bg-dark text-white" type="text"
+       name="search" placeholder="Search UnTube"
+                   value="{{ search_query }}"
+       hx-post="{% url 'search_UnTube' %}"
+       hx-trigger="keyup changed delay:200ms"
+       hx-target="#untube-searchbar-results"
+                   hx-include="[id='searchbar-radio-form']"
+       hx-indicator=".htmx-indicator" autofocus onfocus="this.setSelectionRange(this.value.length,this.value.length);">
+            <br>
+                          <div id="searchbar-radio-form">
+
+                  <div class="d-flex justify-content-center">
+
+                          <div class="form-check me-5">
+                      <input class="form-check-input" type="radio" name="search-settings" value="starts-with" id="starts-with-cb" {% if starts_with %} checked {% endif %}>
+                      <label class="form-check-label" for="starts-with-cb">
+                        Starts with
+                      </label>
+                    </div>
+                         <div class="form-check">
+                      <input class="form-check-input" type="radio" name="search-settings" value="contains" id="contains-cb" {% if contains %} checked {% endif %}>
+                      <label class="form-check-label" for="contains-cb">
+                        Contains
+                      </label>
+                    </div>
+                      </div>
+                  </div>
+
+        </div>
+
+        <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+            <h1 class="h2">Playlists <span class="badge bg-primary rounded-pill">{{ playlists.count|default:"0" }}</span></h1>
+        </div>
+
+        <div>
+            <div class="row row-cols-1 row-cols-md-3 g-4">
+                {% if playlists %}
+                {% for playlist in playlists %}
+                <div class="col">
+                    <div class="card h-100" style="background-color: #1A4464;">
+                        <a style="background-color: #1A4464;" href="{% url 'playlist' playlist.playlist_id %}" class="list-group-item list-group-item-action" aria-current="true">
+
+                            <div class="card-body text-white">
+
+                                <h5 class="card-title">
+                                    {{ playlist.name }}
+                                    {% if playlist.is_private_on_yt %}<small><span class="badge bg-light text-dark">Private</span></small> {% endif %}
+                                    {% if playlist.is_from_yt %}<small><span class="badge bg-danger text-dark">YT</span></small> {% endif %}
+                                </h5>
+                                <p class="card-text">
+                                    {% if playlist.description %}
+                                        {{ playlist.description }}
+                                    {% else %}
+                                        No description
+                                    {% endif %}
+                                </p>
+                                <small>
+                                    <span class="badge bg-primary rounded-pill">{{ playlist.video_count }} views</span>
+                                    <span class="badge bg-primary rounded-pill">{{ playlist.playlist_duration }} </span>
+                                </small>
+                            </div>
+                        </a>
+                    </div>
+                </div>
+                {% endfor %}
+
+                {% else %}
+                <h5 class="text-white align-content-center">Nothing found :(</h5>
+                {% endif %}
+            </div>
+        </div>
+
+        <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+            <h1 class="h2">Videos <span class="badge bg-primary rounded-pill">{{ videos_count }}</span></h1>
+        </div>
+
+                <div>
+            <div class="row row-cols-1 row-cols-md-3 g-4">
+                {% if videos %}
+                {% for video in videos %}
+                <div class="col">
+                    <div class="card" style="background-color: #1A4464;">
+
+                            <div class="card-body">
+                                <h5 class="card-title text-light">
+                                    {{ video.name }}<br>
+
+                                    <small><span class="badge bg-dark text-white-50">{{ video.duration }}</span></small>
+                                    {% if video.is_unavailable_on_yt %}<small><span class="badge bg-light text-dark">Private</span></small> {% endif %}
+                                    {% if video.has_cc %}<small><span class="badge bg-danger text-dark">CC</span></small> {% endif %}
+                                </h5>
+                                                                <br>
+
+                                <span class="d-flex justify-content-center">
+                                    <a href="https://www.youtube.com/watch?v={{ video.video_id }}" class="btn btn-info me-1" target="_blank" id="share_link" style=""><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" data-clipboard-target="#video-{{ video.video_id }}">
+                                        <i class="far fa-copy" aria-hidden="true"></i>
+                                    </button>
+                                </span>
+                            </div>
+                    </div>
+                </div>
+                {% endfor %}
+
+                {% else %}
+                <h5 class="text-white align-content-center">Nothing found :(</h5>
+                {% endif %}
+            </div>
+        </div>
+
+    <br>

+ 22 - 0
apps/main/templates/intercooler/video_details.html

@@ -0,0 +1,22 @@
+    {% load humanize %}
+
+
+    <div class="offcanvas-header text-dark">
+    <h5 class="offcanvas-title" id="offcanvasWithBackdropLabel">
+        {{ 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>
+    </h5>
+    <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>  </div>
+  <div class="offcanvas-body text-dark">
+      <form>
+                <p>{{ video.description|linebreaksbr|urlize }}</p>
+    </form>
+      <br style="background-color: cadetblue">
+  </div>
+
+

+ 19 - 0
apps/main/templates/intercooler/video_notes.html

@@ -0,0 +1,19 @@
+
+                <div class="offcanvas-body text-white-50">
+                    <div id="video-notes-status-div" class="text-white">
+
+                    </div>
+                    <div id="video-notes-form">
+                          <div class="form-group text-white">
+                            <h1>Your Notes</h1>
+                                                  <br>
+
+                            <textarea name="video-notes-text-area" class="form-control" id="video-notes-text-area" rows="17">{{ video.user_notes }}</textarea>
+                          </div>
+                    </div>
+                    <br>
+                    <button type="button" hx-post="{% url 'video_notes' playlist_id video.video_id %}" hx-include="[id='video-notes-form']" hx-target="#video-notes-status-div" class="btn btn-success">Save</button>
+
+                    <br>
+                </div>
+    

+ 40 - 0
apps/main/templates/intercooler/videos.html

@@ -0,0 +1,40 @@
+{% load humanize %}
+
+
+<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">
+                </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>
+                    </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>
+
+                        <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>
+
+  {% endif %}
+  {% endfor %}
+</li>
+</div>

+ 98 - 0
apps/main/templates/playlists_home.html

@@ -0,0 +1,98 @@
+{% extends 'base.html' %}
+{% block content %}
+    <br>
+
+<div class="row row-cols-1 row-cols-md-3 g-4" >
+    <div class="col">
+        <a href="{% url 'all_playlists' 'all' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #64381a;">
+            <div class="card-body">
+                <h4 class="card-title">All Playlists</h4>
+                    <p class="card-text">Everything.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+    <div class="col">
+        <a href="{% url 'all_playlists' 'all' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #1A4464;">
+            <div class="card-body">
+                <h4 class="card-title">Your YouTube Playlists</h4>
+                    <p class="card-text">View a list of all the playlists you own on YouTube.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+
+    <div class="col">
+        <a href="{% url 'all_playlists' 'all' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #1a645e;">
+            <div class="card-body">
+                <h4 class="card-title">Imported Playlists</h4>
+                <p class="card-text">View a list of all the external playlists you imported from YouTube.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+    <div class="col">
+        <a href="{% url 'all_playlists' 'all' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #aa8c2e;">
+            <div class="card-body">
+                <h4 class="card-title">Favorite Playlists</h4>
+                <p class="card-text">All the playlists you've marked favorite.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+    <div class="col">
+        <a href="{% url 'all_playlists' 'watching' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #541a64;">
+            <div class="card-body">
+                <h4 class="card-title">Watching Playlists</h4>
+                <p class="card-text">All the playlists you're currently watching.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+    <div class="col">
+        <a href="{% url 'all_playlists' 'watching' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #641a29;">
+            <div class="card-body">
+                <h4 class="card-title">On-Hold</h4>
+                <p class="card-text">Stopped watching a playlist? Add it here.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+    <div class="col">
+        <a href="{% url 'all_playlists' 'all' %}" class="text-decoration-none text-white">
+        <div class="card h-100" style="background-color: #969291;">
+            <div class="card-body">
+                <h5 class="card-title">Your UnTube Playlists</h5>
+                <p class="card-text"><!--UnTube playlists are playlists can contain YouTube videos and YouTube playlists which can then be shared with other users via a link.--> Coming soon. Maybe.</p>
+            </div>
+        </div>
+        </a>
+    </div>
+    <div class="col">
+        <a href="{% url 'all_playlists' 'all' %}" class="text-decoration-none text-white">
+
+        <div class="card h-100" style="background-color: #d04623;">
+            <div class="card-body">
+                <h5 class="card-title">Open a Random Playlist</h5>
+                <p class="card-text mt-4">
+                    <button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                              From
+                    </button>
+                  <ul class="dropdown-menu">
+                    <li><a class="dropdown-item" href="#">All</a></li>
+                    <li><a class="dropdown-item" href="#">Watching</a></li>
+                    <li><a class="dropdown-item" href="#">Favorites</a></li>
+                  </ul>
+                </p>
+            </div>
+        </div>
+        </a>
+    </div>
+</div>
+{% endblock %}

+ 246 - 0
apps/main/templates/view_playlist.html

@@ -0,0 +1,246 @@
+
+{% extends 'base.html' %}
+{% load humanize %}
+
+{% block content %}
+
+    {% if playlist.has_playlist_changed %}
+    <br>
+    <div class="alert alert-success alert-dismissible fade show" role="alert">
+        Looks like this playlist changed on YouTube! <a href="#" class="link-success">Update!</a>
+        <a href="#" class="link-primary">Why tf aren't you doing this automatically?</a>
+
+        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+    </div>
+    {% else %}
+    <br>
+    {% endif %}
+
+
+    <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 class="badge bg-success text-white" id="notice-div">{{ playlist.marked_as|title }}</span></h4>
+        </div>
+        <p class="mb-1">
+            {% if playlist.description %}
+            <h5>{{ playlist.description }} {{ playlist. }}</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>
+                        </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 '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 'on-hold' %}" hx-trigger="click" hx-target="#notice-div">On-Hold</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 %}<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 %}
+       <!-- <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>
+        </div>
+    </div> -->
+
+{% endblock %}

+ 3 - 0
apps/main/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 30 - 0
apps/main/urls.py

@@ -0,0 +1,30 @@
+from django.conf.urls import url
+from django.urls import path
+from . import views
+
+urlpatterns = [
+    path("home/", views.home, name='home'),
+    # path("", views.index, name='index'),
+    # path("login/", views.log_in, name='log_in'),
+
+    ### STUFF RELATED TO WHOLE SITE
+    path("search/UnTube/", views.search_UnTube, name="search_UnTube"),
+
+    ### STUFF RELATED TO ONE VIDEO
+    path("<slug:playlist_id>/<slug:video_id>/video-details", views.view_video, name='video_details'),
+    path("<slug:playlist_id>/<slug:video_id>/video-details/notes", views.video_notes, name='video_notes'),
+    path("<slug:playlist_id>/<slug:video_id>/video-details/favorite", views.mark_video_favortie, name='mark_video_favorite'),
+    path("delete-videos", views.delete_videos, name='delete_videos'),
+
+    ### STUFF RELATED TO ONE PLAYLIST
+    path("playlist/<slug:playlist_id>", views.view_playlist, name='playlist'),
+    path("playlist/<slug:playlist_id>/order-by/<slug:order_by>", views.order_playlist_by,
+         name='order_playlist_by'),
+    path("playlist/<slug:playlist_id>/mark-as/<slug:mark_as>", views.mark_playlist_as,
+         name='mark_playlist_as'),
+
+    ### STUFF RELATED TO PLAYLISTS IN BULK
+    path("search/playlists/<slug:playlist_type>", views.search_playlists, name="search_playlists"),
+    path("playlists/<slug:playlist_type>", views.all_playlists, name='all_playlists'),
+    path("playlists/<slug:playlist_type>/order-by/<slug:order_by>", views.order_playlists_by, name='order_playlists_by'),
+]

+ 284 - 0
apps/main/views.py

@@ -0,0 +1,284 @@
+from django.db.models import Q
+from django.http import HttpResponse
+from django.shortcuts import render, redirect
+from apps.main.models import Playlist
+from django.contrib.auth.decorators import login_required  # redirects user to settings.LOGIN_URL
+from allauth.socialaccount.models import SocialToken
+from django.views.decorators.http import require_POST
+from django.contrib import messages
+from django.template import Context, loader
+
+
+# Create your views here.
+@login_required
+def home(request):
+    user_profile = request.user.profile
+    user_playlists = user_profile.playlists.order_by("-num_of_accesses")
+    watching = user_profile.playlists.filter(marked_as="watching").order_by("-num_of_accesses")
+
+    #### FOR NEWLY JOINED USERS ######
+    channel_found = True
+    if user_profile.just_joined:
+        if user_profile.import_in_progress:
+            return render(request, "import_in_progress.html")
+        else:
+            if user_profile.access_token == "" or user_profile.refresh_token == "":
+                user_social_token = SocialToken.objects.get(account__user=request.user)
+                print("refresh toekn", user_social_token.token_secret)
+                print("access token", user_social_token.token)
+                user_profile.access_token = user_social_token.token
+                user_profile.refresh_token = user_social_token.token_secret
+                user_profile.expires_at = user_social_token.expires_at
+
+                request.user.save()
+
+            user_profile.just_joined = False
+            user_profile.save()
+
+            return render(request, "home.html", {"import_successful": True})
+
+        # if Playlist.objects.getUserYTChannelID(request.user) == -1:  # user channel not found
+        #    channel_found = False
+        # else:
+        #   Playlist.objects.initPlaylist(request.user, None)  # get all playlists from user's YT channel
+        #  return render(request, "home.html", {"import_successful": True})
+    ##################################
+
+    if request.method == "POST":
+        print(request.POST)
+        if Playlist.objects.initPlaylist(request.user, request.POST['playlist-id'].strip()) == -1:
+            print("No such playlist found.")
+            playlist = []
+            videos = []
+        else:
+            playlist = user_profile.playlists.get(playlist_id__exact=request.POST['playlist-id'].strip())
+            videos = playlist.videos.all()
+    else:  # GET request
+        videos = []
+        playlist = []
+
+        print("TESTING")
+
+    return render(request, 'home.html', {"channel_found": channel_found,
+                                         "playlist": playlist,
+                                         "videos": videos,
+                                         "user_playlists": user_playlists,
+                                         "watching": watching})
+
+
+@login_required
+def view_video(request, playlist_id, video_id):
+    video = request.user.profile.playlists.get(playlist_id=playlist_id).videos.get(video_id=video_id)
+    print(video.name)
+    return HttpResponse(loader.get_template("intercooler/video_details.html").render({"video": video}))
+
+
+@login_required
+def video_notes(request, playlist_id, video_id):
+    video = request.user.profile.playlists.get(playlist_id=playlist_id).videos.get(video_id=video_id)
+
+    if request.method == "POST":
+        if 'video-notes-text-area' in request.POST:
+            video.user_notes = request.POST['video-notes-text-area']
+            video.save()
+            return HttpResponse(loader.get_template("intercooler/messages.html").render(
+                {"message_type": "success", "message_content": "Saved!"}))
+    else:
+        print("GET VIDEO NOTES")
+
+    return HttpResponse(loader.get_template("intercooler/video_notes.html").render({"video": video,
+                                                                                    "playlist_id": playlist_id}))
+
+
+@login_required
+def view_playlist(request, playlist_id):
+    user_profile = request.user.profile
+    user_playlists = user_profile.playlists.all()
+
+    # specific playlist requested
+    playlist = user_profile.playlists.get(playlist_id__exact=playlist_id)
+    playlist.num_of_accesses += 1
+    playlist.save()
+
+    videos = playlist.videos.all()
+
+    if not playlist.has_playlist_changed:
+        Playlist.objects.checkIfPlaylistChangedOnYT(request.user, playlist_id)
+
+    return render(request, 'view_playlist.html', {"playlist": playlist,
+                                                  "videos": videos,
+                                                  "user_playlists": user_playlists})
+
+
+@login_required
+def all_playlists(request, playlist_type):
+    if playlist_type == "" or playlist_type.lower() == "all":
+        playlists = request.user.profile.playlists.all()
+        playlist_type_display = "All Playlists"
+    elif playlist_type.lower() == "favorites":
+        playlists = request.user.profile.playlists.filter(marked_as="favorite")
+        playlist_type_display = "Favorites"
+    elif playlist_type.lower() == "watching":
+        playlists = request.user.profile.playlists.filter(marked_as="watching")
+        playlist_type_display = "Watching"
+    elif playlist_type.lower() == "home":  # displays cards of all playlist types
+        return render(request, 'playlists_home.html')
+    else:
+        return redirect('home')
+
+    return render(request, 'all_playlists.html', {"playlists": playlists,
+                                                  "playlist_type": playlist_type,
+                                                  "playlist_type_display": playlist_type_display})
+
+
+@login_required
+def order_playlist_by(request, playlist_id, order_by):
+    playlist = request.user.profile.playlists.get(playlist_id=playlist_id)
+
+    if order_by == "popularity":
+        videos = playlist.videos.order_by("-like_count")
+    elif order_by == "date-published":
+        videos = playlist.videos.order_by("-published_at")
+    elif order_by == "views":
+        videos = playlist.videos.order_by("-view_count")
+    elif order_by == "has-cc":
+        videos = playlist.videos.filter(has_cc=True)
+    elif order_by == "duration":
+        videos = playlist.videos.order_by("-duration_in_seconds")
+    else:
+        return redirect('home')
+
+    return HttpResponse(loader.get_template("intercooler/videos.html").render({"playlist": playlist, "videos": videos}))
+
+
+@login_required
+def order_playlists_by(request, playlist_type, order_by):
+    if playlist_type == "" or playlist_type.lower() == "all":
+        playlists = request.user.profile.playlists.all().order_by(f"-{order_by.replace('-', '_')}")
+        playlist_type = "All Playlists"
+    elif playlist_type.lower() == "favorites":
+        playlists = request.user.profile.playlists.filter(is_favorite=True).order_by(f"-{order_by.replace('-', '_')}")
+        playlist_type = "Favorites"
+    elif playlist_type.lower() == "watching":
+        playlists = request.user.profile.playlists.filter(on_watch=True).order_by(f"-{order_by.replace('-', '_')}")
+        playlist_type = "Watching"
+    else:
+        return redirect('home')
+
+    return render(request, 'all_playlists.html', {"playlists": playlists, "playlist_type": playlist_type})
+
+
+@login_required
+def mark_playlist_as(request, playlist_id, mark_as):
+    playlist = request.user.profile.playlists.get(playlist_id=playlist_id)
+
+    if mark_as in ["none", "watching", "on-hold", "plan-to-watch"]:
+        playlist.marked_as = mark_as.replace("-", " ")
+        playlist.save()
+        videos = playlist.videos.all()
+    else:
+        return render('home')
+
+    return HttpResponse(mark_as.replace("-", " "))
+
+
+@login_required
+def playlists_home(request):
+    return render(request, 'playlists_home.html')
+
+
+@login_required
+@require_POST
+def delete_videos(request):
+    print(request.POST)
+    return HttpResponse("Worked!")
+
+
+@login_required
+@require_POST
+def search_playlists(request, playlist_type):
+    # TODO: make an AJAX call to /search to search for a name in user's playlists
+    print(request.POST)  # prints <QueryDict: {'search': ['aa']}>
+
+    search_query = request.POST["search"]
+
+    print(search_query)
+
+    if playlist_type == "all":
+        try:
+            playlists = request.user.profile.playlists.filter(name__startswith=search_query)
+        except:
+            playlists = request.user.profile.playlists.all()
+        playlist_type_display = "All Playlists"
+    elif playlist_type == "watching":
+        try:
+            playlists = request.user.profile.playlists.filter(
+                Q(name__startswith=search_query) & Q(marked_as=playlist_type))
+        except:
+            playlists = request.user.profile.playlists.all()
+        playlist_type_display = "Watching"
+
+    return HttpResponse(loader.get_template("intercooler/playlists.html")
+                        .render({"playlists": playlists,
+                                 "playlist_type_display": playlist_type_display,
+                                 "playlist_type": playlist_type,
+                                 "search_query": search_query}))
+
+
+#### MANAGE VIDEOS
+def mark_video_favortie(request, playlist_id, video_id):
+    video = request.user.profile.playlists.get(playlist_id=playlist_id).videos.get(video_id=video_id)
+
+    if video.is_favorite:
+        video.is_favorite = False
+        video.save()
+        return HttpResponse('<i class="far fa-heart"></i>')
+    else:
+        video.is_favorite = True
+        video.save()
+        return HttpResponse('<i class="fas fa-heart"></i>')
+
+
+@login_required
+@require_POST
+def search_UnTube(request):
+    print(request.POST)
+
+    search_query = request.POST["search"]
+
+    all_playlists = request.user.profile.playlists.all()
+    videos = []
+    starts_with = False
+    contains = False
+
+    if request.POST['search-settings'] == 'starts-with':
+        playlists = request.user.profile.playlists.filter(name__startswith=search_query) if search_query != "" else []
+
+        if search_query != "":
+            for playlist in all_playlists:
+                pl_videos = playlist.videos.filter(name__startswith=search_query)
+
+                if pl_videos.count() != 0:
+                    for v in pl_videos.all():
+                        videos.append(v)
+
+        starts_with = True
+    else:
+        playlists = request.user.profile.playlists.filter(name__contains=search_query) if search_query != "" else []
+
+        if search_query != "":
+            for playlist in all_playlists:
+                pl_videos = playlist.videos.filter(name__contains=search_query)
+
+                if pl_videos.count() != 0:
+                    for v in pl_videos.all():
+                        videos.append(v)
+
+        contains = True
+    return HttpResponse(loader.get_template("intercooler/search_untube.html")
+                        .render({"playlists": playlists,
+                                 "videos": videos,
+                                 "videos_count": len(videos),
+                                 "search_query": search_query,
+                                 "starts_with": starts_with,
+                                 "contains": contains}))

+ 0 - 0
apps/users/__init__.py


+ 3 - 0
apps/users/admin.py

@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.

+ 6 - 0
apps/users/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class UsersConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'apps.users'

+ 45 - 0
apps/users/models.py

@@ -0,0 +1,45 @@
+import re
+from django.contrib.auth.hashers import make_password, check_password
+
+from django.db import models
+from django.contrib.auth.models import User
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+
+# Create your models here.
+class ProfileManager(models.Manager):
+    def updateUserProfile(self, details):
+        pass
+
+
+# extension of the built in User model made by Django
+class Profile(models.Model):
+    user = models.OneToOneField(User, on_delete=models.CASCADE)
+
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    # manage user
+    objects = ProfileManager()
+    just_joined = models.BooleanField(default=True)
+    yt_channel_id = models.CharField(max_length=420, default='')
+    import_in_progress = models.BooleanField(default=True)
+
+    # google api token details
+    access_token = models.TextField(default="")
+    refresh_token = models.TextField(default="")
+    expires_at = models.DateTimeField(blank=True, null=True)
+
+
+# as soon as one User object is created, create an associated profile object
+@receiver(post_save, sender=User)
+def create_user_profile(sender, instance, created, **kwargs):
+    if created:
+        Profile.objects.create(user=instance)
+
+
+# whenever User.save() happens, Profile.save() also happens
+@receiver(post_save, sender=User)
+def save_user_profile(sender, instance, **kwargs):
+    instance.profile.save()

+ 62 - 0
apps/users/templates/index.html

@@ -0,0 +1,62 @@
+
+{% load crispy_forms_tags %}
+
+{% load socialaccount %}
+
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <link rel="icon" href="/docs/4.0/assets/img/favicons/favicon.ico">
+
+    <title>UnTube</title>
+
+      <link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Open+Sans&display=swap" rel="stylesheet">
+
+    <!-- Bootstrap core CSS -->
+    <link href="https://getbootstrap.com/docs/4.0/dist/css/bootstrap.min.css" rel="stylesheet">
+
+    <!-- Custom styles for this template -->
+    <link href="https://getbootstrap.com/docs/4.0/examples/cover/cover.css" rel="stylesheet">
+
+  </head>
+<body style="font-family: 'Fredoka One'">
+
+    <div class="cover-container d-flex h-100 p-3 mx-auto flex-column text-center">
+      <header class="masthead mb-auto">
+          {% for message in messages %}
+                  <div class="alert alert-success alert-success fade show" role="alert">
+                      {{ message }}
+                      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                  </div>
+          {% endfor %}
+
+      </header>
+
+      <main role="main" class="inner cover">
+        <h1 class="cover-heading">UnTube</h1>
+        <p class="lead">UnTube is a simple Youtube playlist manager. Modify and keep track of your YouTube playlists with ease.</p>
+        <p class="lead">
+            <br>
+            <a class="btn btn-outline-danger" href="{% provider_login_url 'google' %}">Login with Google</a>
+
+        </p>
+      </main>
+
+      <footer class="mastfoot mt-auto">
+        <div class="inner">
+          <h6>Made with <span style="color: #e25555;">&hearts;</span> in Django</h6>
+        </div>
+      </footer>
+    </div>
+
+<!-- Bootstrap core JavaScript
+    ================================================== -->
+    <!-- Placed at the end of the document so the pages load faster -->
+    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
+    <script src="https://getbootstrap.com/docs/4.0/assets/js/vendor/popper.min.js"></script>
+    <script src="https://getbootstrap.com/docs/4.0/dist/js/bootstrap.min.js"></script>
+
+  </body>
+</html>

+ 97 - 0
apps/users/templates/test.html

@@ -0,0 +1,97 @@
+
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <meta name="description" content="">
+        <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
+        <meta name="generator" content="Hugo 0.83.1">
+        <title>Import in progress</title>
+
+        <style type="text/css">
+            .progress {
+                height: 20px;
+                margin-bottom: 20px;
+                overflow: hidden;
+                background-color: #f5f5f5;
+                border-radius: 4px;
+                box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
+            }
+            .progress-bar {
+                float: left;
+                width: 0%;
+                height: 100%;
+                font-size: 12px;
+                line-height: 20px;
+                color: #fff;
+                text-align: center;
+                background-color: #337ab7;
+                -webkit-box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
+                box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
+                -webkit-transition: width .6s ease;
+                -o-transition: width .6s ease;
+                transition: width .6s ease;
+            }
+
+        </style>
+
+
+          <script src="https://unpkg.com/htmx.org@1.4.1"></script>
+        <script src="https://unpkg.com/htmx.org/dist/ext/class-tools.js"></script>
+
+
+        <link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Open+Sans&display=swap" rel="stylesheet">
+
+        <!-- Bootstrap core CSS -->
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
+    </head>
+    <body class="bg-dark text-white"  style="font-family: 'Fredoka One'">
+
+        <div class="container-fluid">
+
+            <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                    <h1>UnTube</h1>
+            </div>
+            <br>
+            <br>
+
+            <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                <h3>{{ user.username|capfirst }}, click the button below to import your YouTube playlists into UnTube.</h3>
+            </div>
+
+            <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                <h3>It will take a few minutes depending on the number of playlists you have.</h3>
+            </div>
+            <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+                <h3>You will be redirected automatically. Please be patient :)</h3>
+            </div>
+
+
+                <div hx-target="this" hx-swap="outerHTML">
+                    <div class="d-flex justify-content-center pt-3 pb-2 mb-3">
+
+                      <button class="btn btn-lg btn-success" hx-post="{% url 'start' %}">
+                                Import
+                      </button>
+                    </div>
+                </div>
+
+
+
+
+        </div>
+
+    <script>
+                <!-- for htmx to send csrf_token with every post request -->
+            document.body.addEventListener('htmx:configRequest', (event) => {
+            event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
+          })
+
+    </script>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-gtEjrD/SeCtmISkJkNUaaKMoLD0//ElJ19smozuHV6z3Iehds+3Ulb9Bn9Plx0x4" crossorigin="anonymous"></script>
+    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script><script src="dashboard.js"></script>
+    </body>
+</html>

+ 3 - 0
apps/users/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 15 - 0
apps/users/urls.py

@@ -0,0 +1,15 @@
+from django.conf.urls import include
+from django.urls import path
+from . import views
+
+urlpatterns = [
+    path("", views.index, name='index'),
+    path("logout/", views.log_out, name='log_out'),
+    path("update/settings", views.update_settings, name='update_settings'),
+    path('accounts/', include('allauth.urls')),
+    path("delete/account", views.delete_account, name='delete_account'),
+
+    path("import/start", views.start_import, name='start'),
+    path("import/continue", views.continue_import, name='continue'),
+
+]

+ 139 - 0
apps/users/views.py

@@ -0,0 +1,139 @@
+from django.shortcuts import render, redirect
+from django.contrib.auth import logout
+from django.views.decorators.http import require_POST
+from django.contrib.auth.decorators import login_required
+from allauth.socialaccount.models import SocialToken
+from django.http import HttpResponse
+from django.contrib.auth.models import User
+from django.contrib import messages
+from apps.main.models import Playlist
+from django.template import loader
+
+
+# Create your views here.
+def index(request):
+    if request.user.is_anonymous:
+        return render(request, 'index.html')
+    else:
+        return redirect('home')
+
+
+@require_POST
+def update_settings(request):
+    print(request.POST)
+    user = request.user
+    username_input = request.POST['username'].strip()
+    message_content = "Saved! Refresh to see changes!"
+    message_type = "success"
+    if username_input != user.username:
+        if User.objects.filter(username__exact=username_input).count() != 0:
+            message_type = "danger"
+            message_content = f"Username {request.POST['username'].strip()} already taken"
+        else:
+            user.username = request.POST['username'].strip()
+            user.save()
+            message_content = f"Username updated to {username_input}!"
+
+    return HttpResponse(loader.get_template("intercooler/messages.html").render(
+        {"message_type": message_type, "message_content": message_content}))
+
+
+@login_required
+def delete_account(request):
+    request.user.profile.delete()
+    request.user.delete()
+    request.session.flush()
+    messages.success(request, "Account data deleted successfully.")
+
+    return redirect('index')
+
+
+def log_out(request):
+    request.session.flush()  # delete all stored session keys
+    logout(request)  # log out authenticated user
+    return redirect('/')
+
+
+def test(request):
+    return render(request, 'test.html')
+
+
+def start_import(request):
+    '''
+    Initializes only the user's playlist data in the database. Returns the progress bar, which will
+    keep calling continue_import
+    :param request:
+    :return:
+    '''
+    user_profile = request.user.profile
+
+    if user_profile.access_token == "" or user_profile.refresh_token == "":
+        user_social_token = SocialToken.objects.get(account__user=request.user)
+        user_profile.access_token = user_social_token.token
+        user_profile.refresh_token = user_social_token.token_secret
+        user_profile.expires_at = user_social_token.expires_at
+
+        request.user.save()
+
+    result = Playlist.objects.getAllPlaylistsFromYT(request.user)
+    channel_found = True
+    if result["status"] == -1:
+        print("User has no YT channel")
+        channel_found = False
+
+        return HttpResponse(loader.get_template('intercooler/progress_bar.html').render(
+            {"channel_found": channel_found}
+        ))
+    elif result["status"] == -2:
+        print("User has no playlists on YT")
+
+        return HttpResponse(loader.get_template('intercooler/progress_bar.html').render(
+            {"total_playlists": 0,
+             "playlists_imported": 0,
+             "done": True,
+             "progress": 100,
+             "channel_found": channel_found}))
+    else:
+        return HttpResponse(loader.get_template('intercooler/progress_bar.html').render(
+            {"total_playlists": result["num_of_playlists"],
+             "playlist_name": result["first_playlist_name"],
+             "playlists_imported": 0,
+             "progress": 0,
+             "channel_found": channel_found}
+        ))
+
+
+def continue_import(request):
+    if request.user.profile.import_in_progress is False:
+        return redirect('home')
+
+    num_of_playlists = request.user.profile.playlists.count()
+
+    try:
+        remaining_playlists = request.user.profile.playlists.filter(is_in_db=False)
+        playlists_imported = num_of_playlists - remaining_playlists.count() + 1
+        playlist = remaining_playlists.order_by("created_at")[0]
+        playlist_name = playlist.name
+        playlist_id = playlist.playlist_id
+        Playlist.objects.getAllVideosForPlaylist(request.user, playlist.playlist_id)
+    except:
+        playlist_id = -1
+
+    if playlist_id != -1:
+        return HttpResponse(loader.get_template('intercooler/progress_bar.html').render(
+            {"total_playlists": num_of_playlists,
+             "playlists_imported": playlists_imported,
+             "playlist_name": playlist_name,
+             "progress": round((playlists_imported / num_of_playlists) * 100, 1),
+             "channel_found": True}))
+    else:
+        # request.user.profile.just_joined = False
+        request.user.profile.import_in_progress = False
+        request.user.save()
+
+        return HttpResponse(loader.get_template('intercooler/progress_bar.html').render(
+            {"total_playlists": num_of_playlists,
+             "playlists_imported": num_of_playlists,
+             "done": True,
+             "progress": 100,
+             "channel_found": True}))

+ 22 - 0
manage.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+    """Run administrative tasks."""
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'UnTube.settings')
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+    main()

+ 0 - 0
modules/__init__.py


+ 403 - 0
templates/base.html

@@ -0,0 +1,403 @@
+
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <meta name="description" content="">
+        <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
+        <meta name="generator" content="Hugo 0.83.1">
+        <title>UnTube</title>
+
+        <style type="text/css">
+            #btn-back-to-top {
+              position: fixed;
+              bottom: 20px;
+              right: 20px;
+              display: none;
+                z-index: 4;
+            }
+
+            .big-checkbox {width: 30px; height: 30px;}
+
+        </style>
+
+          <script src="https://unpkg.com/htmx.org@1.4.1"></script>
+        <script src="https://unpkg.com/htmx.org/dist/ext/class-tools.js"></script>
+            <script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.8/dist/clipboard.min.js"></script>
+
+
+        <link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Open+Sans&display=swap" rel="stylesheet">
+
+        <script src="https://kit.fontawesome.com/5baac7e9b7.js" crossorigin="anonymous"></script>
+
+        <!-- Bootstrap core CSS -->
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
+    </head>
+    <body class="bg-dark text-white"  style="font-family: 'Fredoka One'">
+        <nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
+            <div class="container-fluid">
+                <a class="navbar-brand" href="{% url 'home' %}"><h3>UnTube</h3></a>
+                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+                    <span class="navbar-toggler-icon"></span>
+                </button>
+                <div class="collapse navbar-collapse" id="navbarSupportedContent">
+                    <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+                        <li class="nav-item">
+                            <a class="nav-link" aria-current="page" href="{% url 'home' %}">Dashboard</a>
+                        </li>
+
+                        <li class="nav-item">
+                            <a class="nav-link" href="{% url 'all_playlists' 'home' %}">Library</a>
+                        </li>
+                        <li class="nav-item dropdown">
+                            <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
+                                Quick View
+                            </a>
+                            <ul class="dropdown-menu" aria-labelledby="navbarDropdown">
+                                <li><a class="dropdown-item" href="{% url 'all_playlists' 'favorites' %}">Favorites</a></li>
+                                <li><a class="dropdown-item" href="{% url 'all_playlists' 'watching' %}">Watching</a></li>
+                                <li><a class="dropdown-item" href="{% url 'all_playlists' 'watching' %}">On Hold</a></li>
+                                <li><a class="dropdown-item" href="{% url 'all_playlists' 'watching' %}">YouTube</a></li>
+                                <li><a class="dropdown-item" href="{% url 'all_playlists' 'watching' %}">UnTube</a></li>
+                                <li><hr class="dropdown-divider"></li>
+                                <li><a class="dropdown-item" href="{% url 'all_playlists' 'all' %}">View all</a></li>
+                            </ul>
+                        </li>
+
+                        <li class="nav-item">
+                            <a class="nav-link" href="{% url 'all_playlists' 'home' %}">Manage</a>
+                        </li>
+                        <li class="nav-item">
+                            <a class="nav-link" data-bs-toggle="offcanvas" href="#offcanvasExample" role="button" aria-controls="offcanvasExample">
+                        Settings </a>
+                        </li>
+                    </ul>
+
+
+                    <div class="navbar-nav justify-content-end">
+                        <input class="form-control me-lg-2 bg-dark mb-1" id="unTubeSearchBar" type="text" placeholder="Search UnTube">
+                                <button type="button" class="btn btn-primary visually-hidden" id="unTubeSearchBtn" data-bs-toggle="modal" data-bs-target="#unTubeSearchBarResultsModal">
+                                  Launches search model
+                                </button>
+                    </div>
+                    <a class="btn btn-outline-danger mb-1" href="{% url 'log_out' %}">
+                      Log out
+                    </a>
+                </div>
+            </div>
+        </nav>
+
+        <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 %}
+
+            <!-- Button trigger modal -->
+
+
+        <!-- Modal -->
+        <div class="modal fade bg-dark" id="unTubeSearchBarResultsModal" tabindex="-1" aria-labelledby="unTubeSearchBarResultsModalLabel" aria-hidden="true">
+          <div class="modal-dialog modal-xl modal-dialog-scrollable bg-dark">
+            <div class="modal-content bg-dark">
+              <div class="modal-body bg-dark">
+              <div id="untube-searchbar-results">
+                <input class="form-control me-lg-2 bg-dark text-white" id="unTubeSearchBar" type="text"
+                           name="search" placeholder="Search UnTube"
+                           hx-post="{% url 'search_UnTube' %}"
+                           hx-trigger="keyup changed delay:500ms"
+                           hx-target="#untube-searchbar-results"
+                       hx-include="[id='searchbar-radio-form']"
+                           hx-indicator=".htmx-indicator" autofocus>
+                  <br>
+                  <div id="searchbar-radio-form">
+                    <div class="d-flex justify-content-center">
+                     <div class="form-check me-5">
+                      <input class="form-check-input" type="radio" name="search-settings" value="starts-with" id="starts-with-cb" checked>
+                      <label class="form-check-label" for="starts-with-cb">
+                        Starts with
+                      </label>
+                    </div>
+                     <div class="form-check">
+                      <input class="form-check-input" type="radio" name="search-settings" value="contains" id="contains-cb">
+                      <label class="form-check-label" for="contains-cb">
+                        Contains
+                      </label>
+                    </div>
+                  </div>
+                  </div>
+                </div>
+              </div>
+
+            </div>
+          </div>
+        </div>
+
+        </main>
+
+
+      </div>
+    </div>
+
+
+        <div class="offcanvas offcanvas-end text-white-50" style="background-color: #181A1B" tabindex="-1" id="offcanvasExample" aria-labelledby="offcanvasExampleLabel">
+            <div class="offcanvas-header text-white">
+                <h2 class="offcanvas-title" id="offcanvasExampleLabel">Settings</h2>
+                <button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
+            </div>
+
+            <div class="offcanvas-body">
+                <div id="settings-status-div" class="text-dark">
+
+                </div>
+                <div id="settings-form">
+
+                    <div class="mb-3">
+                        <label for="username" class="form-label">Username</label>
+                        <input type="text" class="form-control" name="username" id="username" value="{{ user.username }}">
+                    </div>
+
+                    <fieldset disabled>
+                        <div class="mb-3">
+                            <label for="email" class="form-label">Email Address</label>
+                            <input type="email" class="form-control" id="email" aria-describedby="emailHelp" value="{{ user.email }}">
+                            <div id="emailHelp" class="form-text">This is the google account you logged in with.</div>
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="fullname" class="form-label">Full Name</label>
+                            <input type="text" class="form-control" id="fullname" value="{{ user.get_full_name }}">
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="ytchannelid" class="form-label">YouTube Channel ID</label>
+                            <input type="text" class="form-control" id="ytchannelid" aria-describedby="emailHelp" value="{{ user.profile.yt_channel_id }}">
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="accesstoken" class="form-label">Access Token</label>
+                            <input type="text" class="form-control" id="accesstoken" value="{{ user.profile.access_token }}">
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="refreshtoken" class="form-label">Refresh Token</label>
+                            <input type="text" class="form-control" id="refreshtoken" value="{{ user.profile.refresh_token }}">
+                        </div>
+
+                        <div class="mb-3">
+                            <label for="expiresat" class="form-label">Expires At</label>
+                            <input type="datetime-local" class="form-control" id="expiresat" value="{{ user.profile.expires_at }}">
+                        </div>
+                    </fieldset>
+
+                    <div class="mb-3 form-check form-switch">
+                        <input class="form-check-input" name="auto refresh playlists" type="checkbox" id="flexSwitchCheckDefault">
+                        <label class="form-check-label" for="flexSwitchCheckDefault">Automatically refresh playlists on visit</label>
+                    </div>
+
+                    <div class="mb-3 form-check form-switch">
+                        <input class="form-check-input" name="hide videos" type="checkbox" id="flexSwitchCheckChecked" checked>
+                        <label class="form-check-label" for="flexSwitchCheckChecked">Hide deleted/private videos</label>
+                    </div>
+
+
+                    <div class="mb-3 form-check form-switch">
+                        <input class="form-check-input" name="confirm before deleting" type="checkbox" id="flexSwitchCheckChecked" checked>
+                        <label class="form-check-label" for="flexSwitchCheckChecked">Confirm before deleting</label>
+                    </div>
+                </div>
+
+                    <button type="button" hx-post="{% url 'update_settings' %}" hx-include="[id='settings-form']" hx-target="#settings-status-div" class="btn btn-success">Save</button>
+
+                    <a class="btn btn-outline-danger" href="{% url 'delete_account' %}">Delete account</a>
+
+                <br>
+            </div>
+        </div>
+
+        <div class="offcanvas offcanvas-start" style="background-color: #181A1B" tabindex="-1" id="offcanvasForVideoNotes" aria-labelledby="offcanvasForVideoNotes">
+            <div id="video-notes">
+
+            </div>
+        </div>
+
+        <button
+        type="button"
+        class="btn btn-danger btn-floating btn-lg"
+        id="btn-back-to-top"
+        >
+            ▲
+        </button>
+
+        <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 }}';
+          })
+
+
+                var clipboard = new ClipboardJS('.copy-btn');
+
+
+             // Get the input field
+            var input = document.getElementById("unTubeSearchBar");
+
+            // Execute a function when the user releases a key on the keyboard
+            input.addEventListener("click", function(event) {
+
+                document.getElementById("unTubeSearchBtn").click();
+
+            });
+
+            document.getElementById('select-all-btn').onclick = function() {
+                document.getElementById('select-all-btn').style.display = "none";
+                document.getElementById('deselect-all-btn').style.display = "block";
+
+                var checkboxes = document.getElementsByClassName('video-checkboxes');
+                for (var checkbox of checkboxes) {
+                    checkbox.checked = true;
+                }
+            }
+            document.getElementById('deselect-all-btn').onclick = function() {
+                document.getElementById('deselect-all-btn').style.display = "none";
+                document.getElementById('select-all-btn').style.display = "block";
+
+                var checkboxes = document.getElementsByClassName('video-checkboxes');
+                for (var checkbox of checkboxes) {
+                    checkbox.checked = false;
+                }
+            }
+
+             function row1_hide() {
+                 document.getElementById("row1").style.display = "none";
+                 var checkboxes = document.getElementsByClassName('video-checkboxes'); //Cache the collection here, so that even a new element added with the same class later we can avoid querying this again by using the cached collection.
+                 for(var i=0, len=checkboxes.length; i<len; i++)
+                {
+                    checkboxes[i].style.display = "block";
+                }
+                 document.getElementById("row2").style.display = "block";
+             }
+
+             function row1_show() {
+                 document.getElementById("row1").style.display = "block";
+                 var checkboxes = document.getElementsByClassName('video-checkboxes'); //Cache the collection here, so that even a new element added with the same class later we can avoid querying this again by using the cached collection.
+                 for(var i=0, len=checkboxes.length; i<len; i++)
+                {
+                    checkboxes[i].style.display = "none";
+                }
+                document.getElementById("row2").style.display = "none";
+             }
+
+             function select_all_checkboxes(source) {
+              checkboxes = document.getElementsByClassName('big-checkbox');
+              for(var i=0, n=checkboxes.length;i<n;i++) {
+                checkboxes[i].checked = source.checked;
+              }
+            }
+
+            //Get the button
+            let mybutton = document.getElementById("btn-back-to-top");
+
+            // When the user scrolls down 20px from the top of the document, show the button
+            window.onscroll = function () {
+              scrollFunction();
+            };
+
+            function scrollFunction() {
+              if (
+                document.body.scrollTop > 550 ||
+                document.documentElement.scrollTop > 550
+              ) {
+                mybutton.style.display = "block";
+              } else {
+                mybutton.style.display = "none";
+              }
+            }
+            // When the user clicks on the button, scroll to the top of the document
+            mybutton.addEventListener("click", backToTop);
+
+            function backToTop() {
+              document.body.scrollTop = 0;
+              document.documentElement.scrollTop = 0;
+            }
+
+        </script>
+
+        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-gtEjrD/SeCtmISkJkNUaaKMoLD0//ElJ19smozuHV6z3Iehds+3Ulb9Bn9Plx0x4" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script><script src="dashboard.js"></script>
+    </body>
+</html>