File size: 12,070 Bytes
d368963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
try:
    import os
    import io
    import json
    import hashlib
    from googleapiclient.discovery import build
    from googleapiclient.http import MediaIoBaseDownload
    from google.auth.transport.requests import Request
    from google.oauth2.credentials import Credentials
    from tqdm import tqdm
except ImportError as e:
    # Se faltarem as bibliotecas necessárias, levantamos Exception
    raise Exception(
        "Faltam bibliotecas necessárias para o GoogleDriveDownloader. "
        "Instale-as com:\n\n"
        "  pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib tqdm\n\n"
        f"Detalhes do erro: {str(e)}"
    )

class GoogleDriveDownloader:
    """
    Classe para autenticar e baixar arquivos do Google Drive,
    preservando a estrutura de pastas e evitando downloads redundantes.
    - Nunca abrirá navegador se não encontrar token válido (apenas levanta exceção).
    - Pode ler 'credentials.json' e 'token.json' do disco ou das variáveis de ambiente.
    """

    SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
    
    def __init__(self, chunksize=100 * 1024 * 1024):
        """
        :param chunksize: Tamanho (em bytes) de cada chunk ao baixar arquivos.
                          Ex.: 100MB = 100 * 1024 * 1024.
        """
        self.chunksize = chunksize
        self.service = None

    def _get_credentials_from_env_or_file(self):
        """
        Verifica se existem variáveis de ambiente para 'CREDENTIALS' e 'TOKEN'.
        Caso contrário, tenta usar arquivos locais 'credentials.json' e 'token.json'.
        
        Se o token local/ambiente não existir ou for inválido (sem refresh),
        levanta exceção (não abrimos navegador neste fluxo).
        """
        print("Procurando credentials na variavel de ambiente...")
        env_credentials = os.environ.get("CREDENTIALS")  # Conteúdo JSON do client secrets
        env_token = os.environ.get("TOKEN")              # Conteúdo JSON do token

        creds = None

        # 1) Carregar credenciais do ambiente, se houver
        if env_credentials:
            try:
                creds_json = json.loads(env_credentials)
            except json.JSONDecodeError:
                raise ValueError("A variável de ambiente 'CREDENTIALS' não contém JSON válido.")
            
            # Validamos o "client_id" para garantir que seja um JSON de credenciais mesmo
            client_id = (
                creds_json.get("installed", {}).get("client_id") or
                creds_json.get("web", {}).get("client_id")
            )
            if not client_id:
                raise ValueError("Credenciais em memória não parecem válidas. Faltam campos 'client_id'.")

        else:
            # Se não há credenciais no ambiente, tentamos local
            if not os.path.exists("credentials.json"):
                raise FileNotFoundError(
                    "Nenhuma credencial encontrada em ambiente ou no arquivo 'credentials.json'."
                )
            print("Variavel não encontrada, usando credentials.json")
            with open("credentials.json", 'r', encoding='utf-8') as f:
                creds_json = json.load(f)

        print("Procurando tokens na variavel de ambiente...")
        token_data = None
        if env_token:
            try:
                token_data = json.loads(env_token)
            except json.JSONDecodeError:
                raise ValueError("A variável de ambiente 'TOKEN' não contém JSON válido.")
        else:
            # Se não há token no ambiente, checamos arquivo local
            if os.path.exists("token.json"):
                print("Variavel não encontrada, usando token.json")
                with open("token.json", 'r', encoding='utf-8') as tf:
                    token_data = json.load(tf)
            else:
                raise FileNotFoundError(
                    "Não há token no ambiente nem em 'token.json'. "
                    "Não é possível autenticar sem abrir navegador, então abortando."
                )
        
        # 3) Criar credenciais a partir do token_data
        creds = Credentials.from_authorized_user_info(token_data, self.SCOPES)

        # 4) Se expirou, tenta refresh
        if not creds.valid:
            if creds.expired and creds.refresh_token:
                creds.refresh(Request())
                # Salva token atualizado, se estiver usando arquivo local
                if not env_token:  # só sobrescreve se está lendo do disco
                    with open("token.json", 'w', encoding='utf-8') as token_file:
                        token_file.write(creds.to_json())
            else:
                # Se não é válido e não há refresh token, não temos como renovar sem navegador
                raise RuntimeError(
                    "As credenciais de token são inválidas/expiradas e sem refresh token. "
                    "Não é possível abrir navegador neste fluxo, abortando."
                )

        return creds

    def authenticate(self):
        """Cria e armazena o serviço do Drive API nesta instância."""
        creds = self._get_credentials_from_env_or_file()
        self.service = build("drive", "v3", credentials=creds)

    def _list_files_in_folder(self, folder_id):
        """Retorna a lista de itens (arquivos/pastas) diretamente em 'folder_id'."""
        items = []
        page_token = None
        query = f"'{folder_id}' in parents and trashed=false"

        while True:
            response = self.service.files().list(
                q=query,
                spaces='drive',
                fields='nextPageToken, files(id, name, mimeType)',
                pageToken=page_token
            ).execute()
            items.extend(response.get('files', []))
            page_token = response.get('nextPageToken', None)
            if not page_token:
                break
        return items

    def _get_file_metadata(self, file_id):
        """
        Retorna (size, md5Checksum, modifiedTime) de um arquivo no Drive.
        Se algum campo não existir, retorna valor padrão.
        """
        data = self.service.files().get(
            fileId=file_id,
            fields='size, md5Checksum, modifiedTime'
        ).execute()

        size = int(data.get('size', 0))
        md5 = data.get('md5Checksum', '')
        modified_time = data.get('modifiedTime', '')
        return size, md5, modified_time

    def _get_all_items_recursively(self, folder_id, parent_path=''):
        """
        Percorre recursivamente a pasta (folder_id) no Drive,
        retornando lista de dicts (id, name, mimeType, path).
        """
        results = []
        items = self._list_files_in_folder(folder_id)

        for item in items:
            current_path = os.path.join(parent_path, item['name'])
            if item['mimeType'] == 'application/vnd.google-apps.folder':
                results.append({
                    'id': item['id'],
                    'name': item['name'],
                    'mimeType': item['mimeType'],
                    'path': current_path
                })
                sub = self._get_all_items_recursively(item['id'], current_path)
                results.extend(sub)
            else:
                results.append({
                    'id': item['id'],
                    'name': item['name'],
                    'mimeType': item['mimeType'],
                    'path': parent_path
                })
        return results

    def _needs_download(self, local_folder, file_info):
        """
        Verifica se o arquivo em 'file_info' precisa ser baixado.
        - Se não existir localmente, retorna True.
        - Se existir, compara tamanho e MD5 (quando disponível).
        - Retorna True se for diferente, False se for idêntico.
        """
        file_id = file_info['id']
        file_name = file_info['name']
        rel_path = file_info['path']

        drive_size, drive_md5, _ = self._get_file_metadata(file_id)
        full_local_path = os.path.join(local_folder, rel_path, file_name)

        if not os.path.exists(full_local_path):
            return True  # Não existe localmente

        local_size = os.path.getsize(full_local_path)
        if local_size != drive_size:
            return True

        if drive_md5:
            with open(full_local_path, 'rb') as f:
                local_md5 = hashlib.md5(f.read()).hexdigest()
            if local_md5 != drive_md5:
                return True

        return False

    def _download_single_file(self, file_id, file_name, relative_path, progress_bar):
        """
        Faz download de um único arquivo do Drive, atualizando a barra de progresso global.
        """
        # Como fizemos 'os.chdir(local_folder)' antes, 'relative_path' pode ser vazio.
        # Então concatenamos sem o local_folder:
        file_path = os.path.join(relative_path, file_name)

        # Se o path do diretório for vazio, cai no '.' para evitar WinError 3
        dir_name = os.path.dirname(file_path) or '.'
        os.makedirs(dir_name, exist_ok=True)

        request = self.service.files().get_media(fileId=file_id)
        with io.FileIO(file_path, 'wb') as fh:
            downloader = MediaIoBaseDownload(fh, request, chunksize=self.chunksize)
            done = False
            previous_progress = 0

            while not done:
                status, done = downloader.next_chunk()
                if status:
                    current_progress = status.resumable_progress
                    chunk_downloaded = current_progress - previous_progress
                    previous_progress = current_progress
                    progress_bar.update(chunk_downloaded)

    def download_from_folder(self, drive_folder_id: str, local_folder: str):
        """
        Método principal para:
         1. Autenticar sem abrir navegador (usa token local/ambiente).
         2. Exibir "Iniciando verificação de documentos".
         3. Listar recursivamente arquivos da pasta do Drive.
         4. Verificar quais precisam de download.
         5. Baixar apenas o necessário, com barra de progresso única.
        """
        print("Iniciando verificação de documentos")

        if not self.service:
            self.authenticate()

        print("Buscando lista de arquivos no Drive...")
        all_items = self._get_all_items_recursively(drive_folder_id)

        # Filtra apenas arquivos (exclui subpastas)
        all_files = [f for f in all_items if f['mimeType'] != 'application/vnd.google-apps.folder']

        print("Verificando quais arquivos precisam ser baixados...")
        files_to_download = []
        total_size_to_download = 0
        for info in all_files:
            if self._needs_download(local_folder, info):
                drive_size, _, _ = self._get_file_metadata(info['id'])
                total_size_to_download += drive_size
                files_to_download.append(info)

        if not files_to_download:
            print("Nenhum arquivo novo ou atualizado. Tudo sincronizado!")
            return

        print("Calculando total de bytes a serem baixados...")

        # Ajusta a pasta local e cria se necessário
        os.makedirs(local_folder, exist_ok=True)

        # Muda diretório de trabalho para simplificar criação de subpastas
        old_cwd = os.getcwd()
        os.chdir(local_folder)

        # Cria a barra de progresso global
        progress_bar = tqdm(
            total=total_size_to_download,
            unit='B',
            unit_scale=True,
            desc='Baixando arquivos'
        )

        # Baixa só o que precisa
        for file_info in files_to_download:
            self._download_single_file(
                file_id=file_info['id'],
                file_name=file_info['name'],
                relative_path=file_info['path'],
                progress_bar=progress_bar
            )

        progress_bar.close()
        os.chdir(old_cwd)

        print("Download concluído com sucesso!")